openclacky 0.9.34 → 0.9.35
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 +30 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +14 -10
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/tool_executor.rb +13 -16
- data/lib/clacky/agent/tool_registry.rb +0 -3
- data/lib/clacky/agent.rb +63 -38
- data/lib/clacky/agent_config.rb +5 -1
- data/lib/clacky/brand_config.rb +11 -27
- data/lib/clacky/cli.rb +36 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
- data/lib/clacky/default_skills/new/SKILL.md +1 -1
- data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
- data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
- data/lib/clacky/idle_compression_timer.rb +8 -0
- data/lib/clacky/json_ui_controller.rb +2 -1
- data/lib/clacky/plain_ui_controller.rb +10 -3
- data/lib/clacky/platform_http_client.rb +161 -1
- data/lib/clacky/server/channel/channel_manager.rb +5 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
- data/lib/clacky/server/http_server.rb +235 -40
- data/lib/clacky/server/scheduler.rb +17 -16
- data/lib/clacky/server/session_registry.rb +1 -5
- data/lib/clacky/server/web_ui_controller.rb +7 -6
- data/lib/clacky/session_manager.rb +22 -0
- data/lib/clacky/skill.rb +19 -3
- data/lib/clacky/skill_loader.rb +5 -59
- data/lib/clacky/tools/browser.rb +25 -73
- data/lib/clacky/tools/security.rb +326 -0
- data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
- data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
- data/lib/clacky/tools/terminal/session_manager.rb +208 -0
- data/lib/clacky/tools/terminal.rb +818 -0
- data/lib/clacky/tools/todo_manager.rb +6 -16
- data/lib/clacky/tools/trash_manager.rb +2 -2
- data/lib/clacky/ui2/components/input_area.rb +11 -2
- data/lib/clacky/ui2/layout_manager.rb +438 -488
- data/lib/clacky/ui2/output_buffer.rb +310 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -21
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/encoding.rb +1 -1
- data/lib/clacky/utils/environment_detector.rb +43 -0
- data/lib/clacky/utils/model_pricing.rb +3 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +479 -178
- data/lib/clacky/web/app.js +146 -4
- data/lib/clacky/web/auth.js +101 -0
- data/lib/clacky/web/i18n.js +35 -1
- data/lib/clacky/web/index.html +9 -2
- data/lib/clacky/web/sessions.js +254 -15
- data/lib/clacky/web/skills.js +20 -6
- data/lib/clacky/web/tasks.js +54 -2
- data/lib/clacky/web/theme.js +58 -20
- data/lib/clacky/web/ws.js +11 -2
- data/lib/clacky.rb +2 -2
- metadata +8 -3
- data/lib/clacky/tools/safe_shell.rb +0 -608
- data/lib/clacky/tools/shell.rb +0 -522
|
@@ -7,7 +7,6 @@ require "thread"
|
|
|
7
7
|
require "fileutils"
|
|
8
8
|
require "tmpdir"
|
|
9
9
|
require "uri"
|
|
10
|
-
require "open3"
|
|
11
10
|
require "securerandom"
|
|
12
11
|
require "timeout"
|
|
13
12
|
require_relative "session_registry"
|
|
@@ -169,7 +168,8 @@ module Clacky
|
|
|
169
168
|
@version_mutex = Mutex.new
|
|
170
169
|
@scheduler = Scheduler.new(
|
|
171
170
|
session_registry: @registry,
|
|
172
|
-
session_builder: method(:build_session)
|
|
171
|
+
session_builder: method(:build_session),
|
|
172
|
+
task_runner: method(:run_agent_task)
|
|
173
173
|
)
|
|
174
174
|
@channel_manager = Clacky::Channel::ChannelManager.new(
|
|
175
175
|
session_registry: @registry,
|
|
@@ -180,6 +180,18 @@ module Clacky
|
|
|
180
180
|
)
|
|
181
181
|
@browser_manager = Clacky::BrowserManager.instance
|
|
182
182
|
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.load)
|
|
183
|
+
# Access key authentication:
|
|
184
|
+
# - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
|
|
185
|
+
# - Any other bind address requires CLACKY_ACCESS_KEY env var.
|
|
186
|
+
@localhost_only = local_host?(@host)
|
|
187
|
+
@access_key = @localhost_only ? nil : resolve_access_key
|
|
188
|
+
@auth_failures = {}
|
|
189
|
+
@auth_failures_mutex = Mutex.new
|
|
190
|
+
if @localhost_only
|
|
191
|
+
Clacky::Logger.info("[HttpServer] Localhost mode — authentication disabled")
|
|
192
|
+
else
|
|
193
|
+
Clacky::Logger.info("[HttpServer] Public mode — access key authentication ENABLED")
|
|
194
|
+
end
|
|
183
195
|
end
|
|
184
196
|
|
|
185
197
|
def start
|
|
@@ -318,7 +330,8 @@ module Clacky
|
|
|
318
330
|
path = req.path
|
|
319
331
|
method = req.request_method
|
|
320
332
|
|
|
321
|
-
|
|
333
|
+
# Access key guard (skip for WebSocket upgrades)
|
|
334
|
+
return unless check_access_key(req, res)
|
|
322
335
|
|
|
323
336
|
# WebSocket upgrade — no timeout applied (long-lived connection)
|
|
324
337
|
if websocket_upgrade?(req)
|
|
@@ -382,6 +395,7 @@ module Clacky
|
|
|
382
395
|
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
383
396
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
384
397
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
398
|
+
when ["POST", "/api/open-file"] then api_open_file(req, res)
|
|
385
399
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
386
400
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
387
401
|
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
@@ -400,6 +414,9 @@ module Clacky
|
|
|
400
414
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
401
415
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
402
416
|
api_session_skills(session_id, res)
|
|
417
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
|
|
418
|
+
session_id = path.sub("/api/sessions/", "").sub("/export", "")
|
|
419
|
+
api_export_session(session_id, res)
|
|
403
420
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
|
|
404
421
|
session_id = path.sub("/api/sessions/", "").sub("/messages", "")
|
|
405
422
|
api_session_messages(session_id, req, res)
|
|
@@ -758,9 +775,12 @@ module Clacky
|
|
|
758
775
|
}
|
|
759
776
|
end
|
|
760
777
|
json_response(res, 200, {
|
|
761
|
-
ok:
|
|
762
|
-
skills:
|
|
763
|
-
|
|
778
|
+
ok: true,
|
|
779
|
+
skills: local_skills,
|
|
780
|
+
# warning_code lets the frontend render a localized message.
|
|
781
|
+
# `warning` is kept for back-compat and as an English fallback.
|
|
782
|
+
warning_code: "remote_unavailable",
|
|
783
|
+
warning: "Could not reach the license server. Showing locally installed skills only."
|
|
764
784
|
})
|
|
765
785
|
end
|
|
766
786
|
end
|
|
@@ -855,17 +875,112 @@ module Clacky
|
|
|
855
875
|
end
|
|
856
876
|
end
|
|
857
877
|
|
|
878
|
+
# Returns true when the bind host is loopback-only.
|
|
879
|
+
private def local_host?(host)
|
|
880
|
+
["127.0.0.1", "::1", "localhost"].include?(host.to_s.strip)
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# Resolve access key from CLACKY_ACCESS_KEY env var only.
|
|
884
|
+
private def resolve_access_key
|
|
885
|
+
key = ENV.fetch("CLACKY_ACCESS_KEY", "").strip
|
|
886
|
+
key.empty? ? nil : key
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Extract bearer token / query param / cookie from a WEBrick request.
|
|
890
|
+
# Priority: Authorization: Bearer > ?access_key= > Cookie clacky_access_key
|
|
891
|
+
private def extract_key(req)
|
|
892
|
+
auth = req["Authorization"].to_s.strip
|
|
893
|
+
if auth.start_with?("Bearer ")
|
|
894
|
+
token = auth.sub(/\ABearer\s+/i, "").strip
|
|
895
|
+
return token unless token.empty?
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
899
|
+
token = query["access_key"].to_s.strip
|
|
900
|
+
return token unless token.empty?
|
|
901
|
+
|
|
902
|
+
req.cookies.each do |c|
|
|
903
|
+
return c.value if c.name == "clacky_access_key" && !c.value.to_s.empty?
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
nil
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Constant-time string comparison to prevent timing attacks.
|
|
910
|
+
private def secure_compare(a, b)
|
|
911
|
+
return false unless a.bytesize == b.bytesize
|
|
912
|
+
|
|
913
|
+
result = 0
|
|
914
|
+
a.unpack("C*").zip(b.unpack("C*")) { |x, y| result |= x ^ y }
|
|
915
|
+
result.zero?
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# Returns true if the request is authenticated or auth is disabled.
|
|
919
|
+
# Writes 401/429 to res and returns false on failure.
|
|
920
|
+
private def check_access_key(req, res)
|
|
921
|
+
# Localhost binding — always trusted, no auth needed.
|
|
922
|
+
return true if @localhost_only
|
|
923
|
+
return true unless @access_key # public but no key configured (cli already blocked this)
|
|
924
|
+
|
|
925
|
+
ip = req.peeraddr.last rescue "unknown"
|
|
926
|
+
candidate = extract_key(req)
|
|
927
|
+
|
|
928
|
+
# Lazily evict expired lockout entries to prevent unbounded memory growth.
|
|
929
|
+
@auth_failures_mutex.synchronize do
|
|
930
|
+
@auth_failures.delete_if { |_, e| Time.now >= e[:reset_at] }
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# No key provided — reject immediately without counting as a failure.
|
|
934
|
+
if candidate.nil? || candidate.empty?
|
|
935
|
+
json_response(res, 401, {
|
|
936
|
+
error: "Unauthorized: access key required",
|
|
937
|
+
hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
|
|
938
|
+
})
|
|
939
|
+
return false
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Check if IP is currently locked out.
|
|
943
|
+
blocked, wait_secs = @auth_failures_mutex.synchronize do
|
|
944
|
+
entry = @auth_failures[ip]
|
|
945
|
+
if entry && entry[:count] >= 10 && Time.now < entry[:reset_at]
|
|
946
|
+
[true, (entry[:reset_at] - Time.now).ceil]
|
|
947
|
+
else
|
|
948
|
+
[false, 0]
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
if blocked
|
|
953
|
+
json_response(res, 429, { error: "Too many failed attempts", retry_after: wait_secs })
|
|
954
|
+
return false
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
if secure_compare(@access_key, candidate)
|
|
958
|
+
@auth_failures_mutex.synchronize { @auth_failures.delete(ip) }
|
|
959
|
+
return true
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
@auth_failures_mutex.synchronize do
|
|
963
|
+
entry = @auth_failures[ip] ||= { count: 0, reset_at: Time.now + 300 }
|
|
964
|
+
entry[:count] += 1
|
|
965
|
+
Clacky::Logger.warn("[Auth] Failed attempt #{entry[:count]}/10 from #{ip}")
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
json_response(res, 401, {
|
|
969
|
+
error: "Unauthorized: invalid access key",
|
|
970
|
+
hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
|
|
971
|
+
})
|
|
972
|
+
false
|
|
973
|
+
end
|
|
974
|
+
|
|
858
975
|
# Returns true when the configured gem source is the official RubyGems.org.
|
|
859
976
|
# Raises on error — caller's rescue will handle it.
|
|
860
977
|
private def official_gem_source?
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
raise "gem sources -l failed (exit #{result[:exit_code]}): #{result[:stderr]}" unless result[:exit_code] == 0
|
|
978
|
+
output, exit_code = run_shell("gem sources -l")
|
|
979
|
+
raise "gem sources -l failed (exit #{exit_code}): #{output}" unless exit_code&.zero?
|
|
864
980
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
!sources.match?(%r{mirrors\.|aliyun|tuna|ustc|ruby-china})
|
|
981
|
+
Clacky::Logger.info("[Upgrade] gem sources: #{output.strip}")
|
|
982
|
+
output.include?("https://rubygems.org") &&
|
|
983
|
+
!output.match?(%r{mirrors\.|aliyun|tuna|ustc|ruby-china})
|
|
869
984
|
end
|
|
870
985
|
|
|
871
986
|
# Upgrade via `gem update openclacky --no-document` (official RubyGems source).
|
|
@@ -874,15 +989,12 @@ module Clacky
|
|
|
874
989
|
Clacky::Logger.info("[Upgrade] Official source — running: #{cmd}")
|
|
875
990
|
broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
|
|
876
991
|
|
|
877
|
-
|
|
878
|
-
result = shell.execute(command: cmd, soft_timeout: 30, hard_timeout: 300)
|
|
992
|
+
output, exit_code = run_shell(cmd, timeout: 600)
|
|
879
993
|
|
|
880
|
-
Clacky::Logger.info("[Upgrade] exit_code=#{
|
|
881
|
-
Clacky::Logger.info("[Upgrade]
|
|
882
|
-
Clacky::Logger.info("[Upgrade] stderr=#{result[:stderr].to_s.slice(0, 500)}")
|
|
994
|
+
Clacky::Logger.info("[Upgrade] exit_code=#{exit_code}")
|
|
995
|
+
Clacky::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
|
|
883
996
|
|
|
884
|
-
|
|
885
|
-
success = result[:exit_code] == 0
|
|
997
|
+
success = exit_code&.zero? || false
|
|
886
998
|
|
|
887
999
|
broadcast_all(type: "upgrade_log", line: output)
|
|
888
1000
|
finish_upgrade(success, fallback_hint: "gem update openclacky")
|
|
@@ -922,11 +1034,10 @@ module Clacky
|
|
|
922
1034
|
broadcast_all(type: "upgrade_log", line: "Downloading openclacky-#{latest_version}.gem from OSS...\n")
|
|
923
1035
|
Clacky::Logger.info("[Upgrade] Downloading #{gem_url}")
|
|
924
1036
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
broadcast_all(type: "upgrade_log", line: "✗ Download failed: #{dl[:stderr]}\n")
|
|
1037
|
+
shell_cmd = "curl -fsSL '#{gem_url}' -o '#{gem_file}'"
|
|
1038
|
+
dl_out, dl_exit = run_shell(shell_cmd, timeout: 300)
|
|
1039
|
+
unless dl_exit&.zero?
|
|
1040
|
+
broadcast_all(type: "upgrade_log", line: "✗ Download failed: #{dl_out}\n")
|
|
930
1041
|
broadcast_all(type: "upgrade_complete", success: false)
|
|
931
1042
|
return
|
|
932
1043
|
end
|
|
@@ -936,9 +1047,8 @@ module Clacky
|
|
|
936
1047
|
broadcast_all(type: "upgrade_log", line: "Installing...\n")
|
|
937
1048
|
Clacky::Logger.info("[Upgrade] Running: #{cmd}")
|
|
938
1049
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
success = result[:exit_code] == 0
|
|
1050
|
+
output, exit_code = run_shell(cmd, timeout: 600)
|
|
1051
|
+
success = exit_code&.zero? || false
|
|
942
1052
|
|
|
943
1053
|
broadcast_all(type: "upgrade_log", line: output)
|
|
944
1054
|
finish_upgrade(success, fallback_hint: "gem install #{gem_url}")
|
|
@@ -1017,7 +1127,7 @@ module Clacky
|
|
|
1017
1127
|
end
|
|
1018
1128
|
|
|
1019
1129
|
# Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
|
|
1020
|
-
# Uses
|
|
1130
|
+
# Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
1021
1131
|
private def fetch_latest_version_cached
|
|
1022
1132
|
@version_mutex.synchronize do
|
|
1023
1133
|
now = Time.now
|
|
@@ -1039,6 +1149,7 @@ module Clacky
|
|
|
1039
1149
|
# Query the latest openclacky version.
|
|
1040
1150
|
# Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
|
|
1041
1151
|
# then fall back to `gem list -r` (respects user's configured gem source).
|
|
1152
|
+
# Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
1042
1153
|
private def fetch_latest_version_from_gem
|
|
1043
1154
|
fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
|
|
1044
1155
|
end
|
|
@@ -1068,11 +1179,9 @@ module Clacky
|
|
|
1068
1179
|
# Respects the user's configured gem source (rbenv/mise mirrors, etc.).
|
|
1069
1180
|
# Output format: "openclacky (0.9.0)"
|
|
1070
1181
|
private def fetch_latest_version_from_gem_command
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
return nil unless result[:exit_code] == 0
|
|
1182
|
+
out, exit_code = run_shell("gem list -r openclacky", timeout: 30)
|
|
1183
|
+
return nil unless exit_code&.zero?
|
|
1074
1184
|
|
|
1075
|
-
out = result[:stdout].to_s
|
|
1076
1185
|
match = out.match(/^openclacky\s+\(([^)]+)\)/)
|
|
1077
1186
|
match ? match[1].strip : nil
|
|
1078
1187
|
rescue StandardError
|
|
@@ -1086,6 +1195,23 @@ module Clacky
|
|
|
1086
1195
|
false
|
|
1087
1196
|
end
|
|
1088
1197
|
|
|
1198
|
+
# Run a shell command via the unified Terminal tool and return
|
|
1199
|
+
# [output, exit_code] — drop-in replacement for Open3.capture2e.
|
|
1200
|
+
#
|
|
1201
|
+
# Uses Terminal#execute so the command inherits the user's real
|
|
1202
|
+
# login shell (rbenv/mise shims, configured gem mirrors, etc.).
|
|
1203
|
+
# On timeout / still-running, returns [output_so_far, nil].
|
|
1204
|
+
#
|
|
1205
|
+
# The command is routed through the Security layer like any other
|
|
1206
|
+
# Terminal call; server-side commands (`gem ...`, `curl -fsSL ... -o ...`)
|
|
1207
|
+
# pass through unchanged.
|
|
1208
|
+
private def run_shell(command, timeout: 120)
|
|
1209
|
+
result = Clacky::Tools::Terminal.new.execute(command: command, timeout: timeout)
|
|
1210
|
+
output = result[:output].to_s
|
|
1211
|
+
exit_code = result[:exit_code] # nil when the session is still running
|
|
1212
|
+
[output, exit_code]
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1089
1215
|
# ── Channel API ───────────────────────────────────────────────────────────
|
|
1090
1216
|
|
|
1091
1217
|
# GET /api/channels
|
|
@@ -1151,6 +1277,27 @@ module Clacky
|
|
|
1151
1277
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1152
1278
|
end
|
|
1153
1279
|
|
|
1280
|
+
# POST /api/open-file
|
|
1281
|
+
# Opens a local file or directory using the OS default handler.
|
|
1282
|
+
# Used by the Web UI to handle file:// links — browsers block direct
|
|
1283
|
+
# file:// navigation from http:// pages for security reasons.
|
|
1284
|
+
def api_open_file(req, res)
|
|
1285
|
+
path = parse_json_body(req)["path"]
|
|
1286
|
+
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
1287
|
+
|
|
1288
|
+
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1289
|
+
# Convert it to the Linux-side path so File.exist? works.
|
|
1290
|
+
linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
1291
|
+
|
|
1292
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
1293
|
+
|
|
1294
|
+
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
1295
|
+
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
1296
|
+
json_response(res, 200, { ok: true })
|
|
1297
|
+
rescue => e
|
|
1298
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1154
1301
|
# POST /api/channels/:platform
|
|
1155
1302
|
# Body: { fields... } (platform-specific credential fields)
|
|
1156
1303
|
# Saves credentials and optionally (re)starts the adapter.
|
|
@@ -1689,6 +1836,8 @@ module Clacky
|
|
|
1689
1836
|
type: m["type"]
|
|
1690
1837
|
}
|
|
1691
1838
|
end
|
|
1839
|
+
# Filter out auto-injected models (like lite) from UI display
|
|
1840
|
+
models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
|
|
1692
1841
|
json_response(res, 200, { models: models, current_index: @agent_config.current_model_index })
|
|
1693
1842
|
end
|
|
1694
1843
|
|
|
@@ -1831,15 +1980,13 @@ module Clacky
|
|
|
1831
1980
|
agent.rename(new_name)
|
|
1832
1981
|
end
|
|
1833
1982
|
|
|
1834
|
-
#
|
|
1835
|
-
session_data = agent.to_session_data
|
|
1836
|
-
|
|
1837
|
-
# Update pinned field if provided (not stored in agent, only in session file)
|
|
1983
|
+
# Update pinned status if provided
|
|
1838
1984
|
if !pinned.nil?
|
|
1839
|
-
|
|
1985
|
+
agent.pinned = pinned
|
|
1840
1986
|
end
|
|
1841
1987
|
|
|
1842
|
-
|
|
1988
|
+
# Save session data
|
|
1989
|
+
@session_manager.save(agent.to_session_data)
|
|
1843
1990
|
|
|
1844
1991
|
# Broadcast update event
|
|
1845
1992
|
update_data = { type: "session_updated", session_id: session_id }
|
|
@@ -1936,6 +2083,47 @@ module Clacky
|
|
|
1936
2083
|
end
|
|
1937
2084
|
end
|
|
1938
2085
|
|
|
2086
|
+
# Export a session bundle as a .zip download containing:
|
|
2087
|
+
# - session.json (always)
|
|
2088
|
+
# - chunk-*.md (0..N archived conversation chunks)
|
|
2089
|
+
# Useful for debugging — user clicks "download" in the WebUI status bar
|
|
2090
|
+
# and we can ask them to attach the zip to a bug report.
|
|
2091
|
+
def api_export_session(session_id, res)
|
|
2092
|
+
bundle = @session_manager.files_for(session_id)
|
|
2093
|
+
unless bundle
|
|
2094
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2095
|
+
end
|
|
2096
|
+
|
|
2097
|
+
require "zip"
|
|
2098
|
+
|
|
2099
|
+
short_id = bundle[:session][:session_id].to_s[0..7]
|
|
2100
|
+
# Build the zip entirely in memory — session files are small (< few MB).
|
|
2101
|
+
buffer = Zip::OutputStream.write_buffer do |zos|
|
|
2102
|
+
zos.put_next_entry("session.json")
|
|
2103
|
+
zos.write(File.binread(bundle[:json_path]))
|
|
2104
|
+
|
|
2105
|
+
bundle[:chunks].each do |chunk_path|
|
|
2106
|
+
# Preserve original chunk filename so the ordering (chunk-1.md, chunk-2.md, ...) is clear.
|
|
2107
|
+
zos.put_next_entry(File.basename(chunk_path))
|
|
2108
|
+
zos.write(File.binread(chunk_path))
|
|
2109
|
+
end
|
|
2110
|
+
end
|
|
2111
|
+
buffer.rewind
|
|
2112
|
+
data = buffer.read
|
|
2113
|
+
|
|
2114
|
+
filename = "clacky-session-#{short_id}.zip"
|
|
2115
|
+
res.status = 200
|
|
2116
|
+
res.content_type = "application/zip"
|
|
2117
|
+
res["Content-Disposition"] = %(attachment; filename="#{filename}")
|
|
2118
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
2119
|
+
# Force a fresh copy each time — debugging sessions get new chunks over time.
|
|
2120
|
+
res["Cache-Control"] = "no-store"
|
|
2121
|
+
res.body = data
|
|
2122
|
+
rescue => e
|
|
2123
|
+
Clacky::Logger.error("Session export failed: #{e.message}") if defined?(Clacky::Logger)
|
|
2124
|
+
json_response(res, 500, { error: "Export failed: #{e.message}" })
|
|
2125
|
+
end
|
|
2126
|
+
|
|
1939
2127
|
# ── WebSocket ─────────────────────────────────────────────────────────────
|
|
1940
2128
|
|
|
1941
2129
|
def websocket_upgrade?(req)
|
|
@@ -2088,7 +2276,14 @@ module Clacky
|
|
|
2088
2276
|
return unless @registry.exist?(session_id)
|
|
2089
2277
|
|
|
2090
2278
|
session = @registry.get(session_id)
|
|
2091
|
-
|
|
2279
|
+
|
|
2280
|
+
# If session is running, interrupt it first (mimics CLI behavior)
|
|
2281
|
+
if session[:status] == :running
|
|
2282
|
+
interrupt_session(session_id)
|
|
2283
|
+
# Wait briefly for the thread to catch the interrupt and update status
|
|
2284
|
+
# This ensures the agent loop exits cleanly before starting the new task
|
|
2285
|
+
sleep 0.1
|
|
2286
|
+
end
|
|
2092
2287
|
|
|
2093
2288
|
agent = nil
|
|
2094
2289
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
@@ -23,9 +23,13 @@ module Clacky
|
|
|
23
23
|
SCHEDULES_FILE = File.expand_path("~/.clacky/schedules.yml")
|
|
24
24
|
TASKS_DIR = File.expand_path("~/.clacky/tasks")
|
|
25
25
|
|
|
26
|
-
def initialize(session_registry:, session_builder:)
|
|
26
|
+
def initialize(session_registry:, session_builder:, task_runner:)
|
|
27
27
|
@registry = session_registry
|
|
28
28
|
@session_builder = session_builder # callable: (name:, working_dir:) -> session_id
|
|
29
|
+
# Callable that runs a task on an agent with unified status/save/broadcast
|
|
30
|
+
# handling — signature: (session_id, agent, &block). Same contract as
|
|
31
|
+
# the one ChannelManager receives.
|
|
32
|
+
@task_runner = task_runner
|
|
29
33
|
@thread = nil
|
|
30
34
|
@running = false
|
|
31
35
|
@mutex = Mutex.new
|
|
@@ -227,22 +231,19 @@ module Clacky
|
|
|
227
231
|
|
|
228
232
|
Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
rescue => e
|
|
242
|
-
@registry.update(session_id, status: :error, error: e.message)
|
|
243
|
-
Clacky::Logger.error("scheduler_task_failed", task: task_name, session: session_id, error: e)
|
|
244
|
-
end
|
|
234
|
+
agent = nil
|
|
235
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
236
|
+
return unless agent
|
|
237
|
+
|
|
238
|
+
# Delegate to the unified task runner (same code path as manual runs and
|
|
239
|
+
# channel-triggered runs). It handles:
|
|
240
|
+
# * status transitions (:running → :idle/:error)
|
|
241
|
+
# * broadcasting session_update
|
|
242
|
+
# * persisting the session JSON on success/interrupted/error ← the bit we were missing
|
|
243
|
+
# * idle-compression timer lifecycle
|
|
244
|
+
@task_runner.call(session_id, agent) { agent.run(prompt) }
|
|
245
245
|
|
|
246
|
+
Clacky::Logger.info("scheduler_task_dispatched", task: task_name, session: session_id)
|
|
246
247
|
rescue => e
|
|
247
248
|
Clacky::Logger.error("scheduler_fire_error", task: schedule["task"], error: e)
|
|
248
249
|
end
|
|
@@ -265,10 +265,6 @@ module Clacky
|
|
|
265
265
|
|
|
266
266
|
model_info = agent.current_model_info
|
|
267
267
|
|
|
268
|
-
# Load pinned status from disk session file
|
|
269
|
-
disk_session = @session_manager.load(session_id)
|
|
270
|
-
pinned = disk_session ? (disk_session[:pinned] || false) : false
|
|
271
|
-
|
|
272
268
|
{
|
|
273
269
|
id: session[:id],
|
|
274
270
|
name: agent.name,
|
|
@@ -283,7 +279,7 @@ module Clacky
|
|
|
283
279
|
permission_mode: agent.permission_mode,
|
|
284
280
|
source: agent.source.to_s,
|
|
285
281
|
agent_profile: agent.agent_profile.name,
|
|
286
|
-
pinned: pinned,
|
|
282
|
+
pinned: agent.pinned || false,
|
|
287
283
|
}
|
|
288
284
|
end
|
|
289
285
|
end
|
|
@@ -87,7 +87,7 @@ module Clacky
|
|
|
87
87
|
def show_assistant_message(content, files:)
|
|
88
88
|
return if (content.nil? || content.to_s.strip.empty?) && files.empty?
|
|
89
89
|
|
|
90
|
-
emit("assistant_message", content: content, files: files)
|
|
90
|
+
emit("assistant_message", content: content.to_s, files: files)
|
|
91
91
|
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files) }
|
|
92
92
|
end
|
|
93
93
|
|
|
@@ -300,13 +300,14 @@ module Clacky
|
|
|
300
300
|
|
|
301
301
|
# === State updates ===
|
|
302
302
|
|
|
303
|
-
def update_sessionbar(tasks: nil, cost: nil, status: nil)
|
|
303
|
+
def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
304
304
|
data = {}
|
|
305
|
-
data[:tasks]
|
|
306
|
-
data[:cost]
|
|
307
|
-
data[:
|
|
305
|
+
data[:tasks] = tasks if tasks
|
|
306
|
+
data[:cost] = cost if cost
|
|
307
|
+
data[:cost_source] = cost_source if cost_source
|
|
308
|
+
data[:status] = status if status
|
|
308
309
|
emit("session_update", **data) unless data.empty?
|
|
309
|
-
forward_to_subscribers { |sub| sub.update_sessionbar(tasks: tasks, cost: cost, status: status) }
|
|
310
|
+
forward_to_subscribers { |sub| sub.update_sessionbar(tasks: tasks, cost: cost, cost_source: cost_source, status: status) }
|
|
310
311
|
end
|
|
311
312
|
|
|
312
313
|
def update_todos(todos)
|
|
@@ -62,6 +62,28 @@ module Clacky
|
|
|
62
62
|
true
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Return the on-disk files associated with a session: the main JSON file
|
|
66
|
+
# and any "{base}-chunk-*.md" archive files. Used by the export / download
|
|
67
|
+
# endpoint so the UI can bundle everything a user may need for debugging.
|
|
68
|
+
# Returns nil if the session is not found, or a Hash:
|
|
69
|
+
# {
|
|
70
|
+
# session: Hash, # the loaded session metadata
|
|
71
|
+
# json_path: String, # absolute path to session.json
|
|
72
|
+
# chunks: [String] # sorted absolute paths to chunk *.md files
|
|
73
|
+
# }
|
|
74
|
+
def files_for(session_id)
|
|
75
|
+
session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
76
|
+
return nil unless session
|
|
77
|
+
|
|
78
|
+
json_path = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
|
|
79
|
+
return nil unless File.exist?(json_path)
|
|
80
|
+
|
|
81
|
+
base = File.basename(json_path, ".json")
|
|
82
|
+
chunks = Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).sort
|
|
83
|
+
|
|
84
|
+
{ session: session, json_path: json_path, chunks: chunks }
|
|
85
|
+
end
|
|
86
|
+
|
|
65
87
|
# All sessions from disk, newest-first (sorted by created_at).
|
|
66
88
|
# Optional filters:
|
|
67
89
|
# current_dir: (String) if given, sessions matching working_dir come first
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -169,11 +169,27 @@ module Clacky
|
|
|
169
169
|
"/#{identifier}"
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
172
|
+
# Maximum length for a skill's description when injected into the system
|
|
173
|
+
# prompt. Descriptions longer than this are truncated to protect the token
|
|
174
|
+
# budget — a good description is a trigger hint, not a tutorial. Authors
|
|
175
|
+
# still see their full description via `skill.description`; only the
|
|
176
|
+
# system-prompt rendering is truncated.
|
|
177
|
+
#
|
|
178
|
+
# Anthropic's hard limit is 1024, but empirically ~300 chars is enough for
|
|
179
|
+
# reliable triggering (including trigger-phrase lists); longer content
|
|
180
|
+
# belongs in the SKILL.md body.
|
|
181
|
+
DESCRIPTION_MAX_CHARS = 300
|
|
182
|
+
|
|
183
|
+
# Get the description for context loading.
|
|
184
|
+
# Returns the description from frontmatter (or first paragraph of content),
|
|
185
|
+
# hard-capped at {DESCRIPTION_MAX_CHARS} so a single overlong skill can't
|
|
186
|
+
# blow up the system prompt. Truncation is marked with an ellipsis.
|
|
174
187
|
# @return [String]
|
|
175
188
|
def context_description
|
|
176
|
-
@description || extract_first_paragraph
|
|
189
|
+
raw = @description || extract_first_paragraph
|
|
190
|
+
return raw if raw.nil? || raw.length <= DESCRIPTION_MAX_CHARS
|
|
191
|
+
|
|
192
|
+
raw[0, DESCRIPTION_MAX_CHARS - 1] + "…"
|
|
177
193
|
end
|
|
178
194
|
|
|
179
195
|
# Get all supporting files in the skill directory (excluding SKILL.md)
|