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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/message_compressor.rb +32 -8
  5. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent/tool_executor.rb +0 -12
  8. data/lib/clacky/agent.rb +74 -12
  9. data/lib/clacky/api_extension.rb +81 -0
  10. data/lib/clacky/api_extension_loader.rb +13 -1
  11. data/lib/clacky/cli.rb +0 -1
  12. data/lib/clacky/client.rb +14 -17
  13. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  14. data/lib/clacky/default_agents/base_prompt.md +1 -0
  15. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  16. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  17. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  18. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  19. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  20. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  21. data/lib/clacky/json_ui_controller.rb +1 -1
  22. data/lib/clacky/media/base.rb +60 -0
  23. data/lib/clacky/media/dashscope.rb +385 -21
  24. data/lib/clacky/media/gemini.rb +9 -0
  25. data/lib/clacky/media/generator.rb +52 -0
  26. data/lib/clacky/media/openai_compat.rb +166 -0
  27. data/lib/clacky/null_ui_controller.rb +13 -0
  28. data/lib/clacky/plain_ui_controller.rb +1 -1
  29. data/lib/clacky/providers.rb +50 -2
  30. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  31. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +260 -122
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +3 -2
  35. data/lib/clacky/session_manager.rb +35 -4
  36. data/lib/clacky/skill_loader.rb +14 -2
  37. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  38. data/lib/clacky/tools/terminal.rb +0 -43
  39. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  40. data/lib/clacky/ui2/layout_manager.rb +0 -5
  41. data/lib/clacky/ui2/progress_handle.rb +0 -3
  42. data/lib/clacky/ui2/ui_controller.rb +140 -40
  43. data/lib/clacky/ui_interface.rb +10 -1
  44. data/lib/clacky/utils/encoding.rb +25 -0
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +199 -23
  47. data/lib/clacky/web/components/onboard.js +1 -14
  48. data/lib/clacky/web/components/sidebar.js +1 -3
  49. data/lib/clacky/web/features/backup/store.js +23 -0
  50. data/lib/clacky/web/features/backup/view.js +49 -22
  51. data/lib/clacky/web/features/brand/view.js +8 -5
  52. data/lib/clacky/web/features/channels/store.js +1 -20
  53. data/lib/clacky/web/features/mcp/store.js +1 -20
  54. data/lib/clacky/web/features/profile/store.js +1 -13
  55. data/lib/clacky/web/features/profile/view.js +16 -4
  56. data/lib/clacky/web/features/skills/store.js +6 -21
  57. data/lib/clacky/web/features/tasks/view.js +77 -28
  58. data/lib/clacky/web/features/version/store.js +2 -0
  59. data/lib/clacky/web/i18n.js +46 -3
  60. data/lib/clacky/web/index.html +67 -26
  61. data/lib/clacky/web/sessions.js +177 -87
  62. data/lib/clacky/web/settings.js +34 -2
  63. data/lib/clacky/web/ws-dispatcher.js +12 -4
  64. data/lib/clacky.rb +12 -5
  65. 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.start_with?("/api/backup/download") || path == "/api/backup/run"
466
- # Building a tar.gz of ~/.clacky (with session history) can take a while.
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 = URI.decode_www_form(req.query_string.to_s).to_h
732
- limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
733
- before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
734
- q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
735
- q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
736
- date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
737
- type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
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
- # Fetch one extra NON-PINNED row to detect has_more without a separate count query.
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
- json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
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
- # Initial load: newest 20 sessions regardless of source/profile.
5619
- # Single unified query frontend shows all in one time-sorted list.
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, cron_count: @registry.cron_count)
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 alone is not reliable enough in practice it's
5719
- # best-effort against blocked syscalls (socket writes, OpenSSL read,
5720
- # ConditionVariable#wait with a held mutex) and we've seen sessions
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
- # Each transition is logged so that when users report "stuck
5737
- # sessions" we can see in the log whether tier 2/3 ever had to
5738
- # fire that's our signal to dig deeper on the underlying block.
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} tier=1 raise")
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] tier=1 raise failed: #{e.message}")
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
- return unless thread&.alive?
5756
-
5757
- start_interrupt_watchdog(session_id, thread)
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
- # Close every WebSocket connection bound to the given session. Used by
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 = session[:pending_task]
5831
- working_dir = session[:pending_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
- run_agent_task(session_id, agent) { agent.run(prompt) }
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
- # Mark running BEFORE evict_excess_idle! otherwise this session
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.update(session_id, status: :idle, error: nil)
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
- @registry.update(session_id, status: :idle)
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
- @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url)
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
- @registry.with_session(session_id) { |s| s[:thread] = thread }
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|