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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. 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: true,
762
- skills: local_skills,
763
- warning: "Could not reach the license server. Showing locally installed skills only."
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
- shell = Clacky::Tools::Shell.new
862
- result = shell.execute(command: "gem sources -l", soft_timeout: 10, hard_timeout: 15)
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
- sources = result[:stdout].to_s
866
- Clacky::Logger.info("[Upgrade] gem sources: #{sources.strip}")
867
- sources.include?("https://rubygems.org") &&
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
- shell = Clacky::Tools::Shell.new
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=#{result[:exit_code]}")
881
- Clacky::Logger.info("[Upgrade] stdout=#{result[:stdout].to_s.slice(0, 500)}")
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
- output = [result[:stdout], result[:stderr]].join
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
- shell = Clacky::Tools::Shell.new
926
- dl = shell.execute(command: "curl -fsSL '#{gem_url}' -o '#{gem_file}'",
927
- soft_timeout: 60, hard_timeout: 120)
928
- unless dl[:exit_code] == 0
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
- result = shell.execute(command: cmd, soft_timeout: 30, hard_timeout: 300)
940
- output = [result[:stdout], result[:stderr]].join
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 Clacky::Tools::Shell (login shell) so rbenv/mise shims and gem mirrors work correctly.
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
- shell = Clacky::Tools::Shell.new
1072
- result = shell.execute(command: "gem list -r openclacky", soft_timeout: 15, hard_timeout: 30)
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
- # Save session data
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
- session_data[:pinned] = pinned
1985
+ agent.pinned = pinned
1840
1986
  end
1841
1987
 
1842
- @session_manager.save(session_data)
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
- return if session[:status] == :running
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
- # Run the agent in a background thread so the scheduler tick is non-blocking.
231
- Thread.new do
232
- session = @registry.get(session_id)
233
- agent = nil
234
- @registry.with_session(session_id) { |s| agent = s[:agent] }
235
- next unless agent
236
-
237
- @registry.update(session_id, status: :running)
238
- agent.run(prompt)
239
- @registry.update(session_id, status: :idle)
240
- Clacky::Logger.info("scheduler_task_completed", task: task_name, session: session_id)
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] = tasks if tasks
306
- data[:cost] = cost if cost
307
- data[:status] = status if status
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
- # Get the description for context loading
173
- # Returns the description from frontmatter, or first paragraph of content
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)