openclacky 1.3.2 → 1.3.3

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -16,6 +16,7 @@ require "timeout"
16
16
  require "yaml"
17
17
  require "date"
18
18
  require_relative "session_registry"
19
+ require_relative "git_panel"
19
20
  require_relative "web_ui_controller"
20
21
  require_relative "scheduler"
21
22
  require_relative "../brand_config"
@@ -333,6 +334,14 @@ module Clacky
333
334
  server.mount("/api", servlet_class)
334
335
  server.mount("/ws", servlet_class)
335
336
 
337
+ # Health check endpoint — no auth, minimal overhead.
338
+ # Docker / orchestrators can probe this to decide container health.
339
+ server.mount_proc("/health") do |_req, res|
340
+ res.status = 200
341
+ res["Content-Type"] = "application/json"
342
+ res.body = '{"status":"ok"}'
343
+ end
344
+
336
345
  # Mount static file handler for the entire web directory.
337
346
  # Use mount_proc so we can inject no-cache headers on every response,
338
347
  # preventing stale JS/CSS from being served after a gem update.
@@ -347,12 +356,21 @@ module Clacky
347
356
  server.mount_proc("/") do |req, res|
348
357
  if req.path == "/" || req.path == "/index.html"
349
358
  product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
350
- html = File.read(index_html_path).gsub("{{BRAND_NAME}}", product_name)
359
+ pure = req.query["pure"] == "true"
360
+ html = File.read(index_html_path)
361
+ .gsub("{{BRAND_NAME}}", product_name)
362
+ .gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
351
363
  res.status = 200
352
364
  res["Content-Type"] = "text/html; charset=utf-8"
353
365
  res["Cache-Control"] = "no-store"
354
366
  res["Pragma"] = "no-cache"
355
367
  res.body = html
368
+ elsif req.path.start_with?("/webui_ext/")
369
+ self.send(:serve_webui_ext, req, res)
370
+ elsif req.path.start_with?("/agent_ui/")
371
+ self.send(:serve_agent_ui, req, res)
372
+ elsif req.path.start_with?("/panel_ui/")
373
+ self.send(:serve_panel_ui, req, res)
356
374
  else
357
375
  file_handler.service(req, res)
358
376
  res["Cache-Control"] = "no-store"
@@ -465,6 +483,8 @@ module Clacky
465
483
  when ["POST", "/api/config/test"] then api_test_config(req, res)
466
484
  when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
467
485
  when ["GET", "/api/config/media"] then api_get_media_config(res)
486
+ when ["GET", "/api/config/media-output-dir"] then api_get_media_output_dir(res)
487
+ when ["PATCH", "/api/config/media-output-dir"] then api_update_media_output_dir(req, res)
468
488
  when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
469
489
  when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
470
490
  when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
@@ -565,6 +585,27 @@ module Clacky
565
585
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
566
586
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
567
587
  api_session_skills(session_id, res)
588
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
589
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
590
+ action = path[%r{/git/([a-z]+)$}, 1]
591
+ api_session_git(session_id, action, req, res)
592
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
593
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
594
+ api_session_git_commit(session_id, req, res)
595
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
596
+ session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
597
+ api_session_time_machine(session_id, res)
598
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/time_machine/switch$})
599
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
600
+ api_session_time_machine_switch(session_id, req, res)
601
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/diff$})
602
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
603
+ task_id = path[%r{/time_machine/(\d+)/diff$}, 1].to_i
604
+ api_session_time_machine_diff(session_id, task_id, req, res)
605
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/restore_preview$})
606
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
607
+ task_id = path[%r{/time_machine/(\d+)/restore_preview$}, 1].to_i
608
+ api_session_time_machine_restore_preview(session_id, task_id, res)
568
609
  elsif method == "GET" && path == "/api/dirs"
569
610
  api_browse_dirs(req, res)
570
611
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
@@ -576,6 +617,9 @@ module Clacky
576
617
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
577
618
  session_id = path.sub("/api/sessions/", "").sub("/messages", "")
578
619
  api_session_messages(session_id, req, res)
620
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+$})
621
+ session_id = path.sub("/api/sessions/", "")
622
+ api_get_session(session_id, res)
579
623
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
580
624
  session_id = path.sub("/api/sessions/", "")
581
625
  api_rename_session(session_id, req, res)
@@ -683,6 +727,16 @@ module Clacky
683
727
  json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
684
728
  end
685
729
 
730
+ # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
731
+ # Used by the frontend Router when navigating to a session that isn't in
732
+ # the paged sidebar list (search results, URL deep links, share links,
733
+ # browser back/forward, external notifications, etc.).
734
+ def api_get_session(session_id, res)
735
+ row = @registry.snapshot(session_id)
736
+ return json_response(res, 404, { error: "Session not found" }) unless row
737
+ json_response(res, 200, { session: row })
738
+ end
739
+
686
740
  def api_create_session(req, res)
687
741
  body = parse_json_body(req)
688
742
  name = body["name"]
@@ -949,9 +1003,195 @@ module Clacky
949
1003
  @agent_config.save
950
1004
  end
951
1005
 
1006
+ # Absolute path to the user's WebUI extension directory.
1007
+ WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
1008
+
1009
+ # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1010
+ # 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1011
+ # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
1012
+ # 3. official panels — default_agents/_panels/<id>/**/*.js (data-panel)
1013
+ # Prefixed with a registerPanelAgents() call so the client knows which
1014
+ # agent profiles reference each official panel. Never raises.
1015
+ private def webui_ext_script_tags
1016
+ [
1017
+ panel_agents_script,
1018
+ global_ext_script_tags,
1019
+ agent_webui_script_tags,
1020
+ official_panel_script_tags,
1021
+ ].reject(&:empty?).join("\n")
1022
+ end
1023
+
1024
+ # Inline script registering the panel→agents map (which agent profiles
1025
+ # reference each official panel, from their profile.yml `panels:`).
1026
+ private def panel_agents_script
1027
+ map = panel_agents_map
1028
+ return "" if map.empty?
1029
+
1030
+ "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1031
+ end
1032
+
1033
+ # { "git" => ["coding", ...], ... } — scan every agent profile.yml for a
1034
+ # `panels:` list and invert it into panel → referencing agents.
1035
+ private def panel_agents_map
1036
+ result = Hash.new { |h, k| h[k] = [] }
1037
+ agent_profile_dirs.each do |name, dir|
1038
+ yml = File.join(dir, "profile.yml")
1039
+ next unless File.file?(yml)
1040
+
1041
+ data = begin
1042
+ YAML.safe_load(File.read(yml)) || {}
1043
+ rescue StandardError
1044
+ {}
1045
+ end
1046
+ Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1047
+ end
1048
+ result
1049
+ end
1050
+
1051
+ # { agent_name => resolved_dir } across built-in and user agent dirs,
1052
+ # user dir winning on name collision (matches AgentProfile lookup order).
1053
+ private def agent_profile_dirs
1054
+ dirs = {}
1055
+ [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1056
+ next unless Dir.exist?(root)
1057
+
1058
+ Dir.children(root).each do |name|
1059
+ next if name.start_with?("_") # _panels and other non-agent dirs
1060
+ full = File.join(root, name)
1061
+ next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
1062
+
1063
+ dirs[name] = full
1064
+ end
1065
+ end
1066
+ dirs
1067
+ end
952
1068
 
953
- # GET /api/browser/status
954
- # Returns real daemon liveness from BrowserManager (not just yml read).
1069
+ # Global extensions — visible for all agents (unchanged legacy behavior).
1070
+ private def global_ext_script_tags
1071
+ ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
1072
+ end
1073
+
1074
+ # Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
1075
+ # its owning agent so the client only mounts it for that profile.
1076
+ private def agent_webui_script_tags
1077
+ agent_profile_dirs.filter_map do |name, dir|
1078
+ webui = File.join(dir, "webui")
1079
+ next unless Dir.exist?(webui)
1080
+
1081
+ ext_script_block(webui, "/agent_ui/#{name}", id_prefix: "agent/#{name}/", agents: [name])
1082
+ end.reject(&:empty?).join("\n")
1083
+ end
1084
+
1085
+ # Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
1086
+ # the panel→agents map (registerPanelAgents), so a panel only mounts for
1087
+ # agents whose profile.yml references it.
1088
+ private def official_panel_script_tags
1089
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
1090
+ return "" unless Dir.exist?(root)
1091
+
1092
+ Dir.children(root).sort.filter_map do |panel|
1093
+ dir = File.join(root, panel)
1094
+ next unless File.directory?(dir)
1095
+
1096
+ ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
1097
+ end.reject(&:empty?).join("\n")
1098
+ end
1099
+
1100
+ # Emit begin/script/end triples for every *.js under `root`, served from
1101
+ # `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
1102
+ # client can decide visibility. Returns "" when the dir is absent.
1103
+ private def ext_script_block(root, url_base, id_prefix: "", agents: nil, panel: nil)
1104
+ return "" unless Dir.exist?(root)
1105
+
1106
+ Dir.glob(File.join(root, "**", "*.js")).sort.map do |abs|
1107
+ rel = abs.delete_prefix(root + "/")
1108
+ ext_id = id_prefix + rel.delete_suffix(".js")
1109
+ src = "#{url_base}/#{rel}"
1110
+ # Bracket the extension's own <script> with begin/end markers so that
1111
+ # registrations made during its synchronous evaluation are attributed
1112
+ # to it (for crash attribution / disable). Synchronous src scripts run
1113
+ # in document order, so the surrounding inline scripts run immediately
1114
+ # before and after it.
1115
+ "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{agents.to_json}, #{panel.to_json})</script>" \
1116
+ "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1117
+ "<script>Clacky.ext._extEnd()</script>"
1118
+ end.join("\n")
1119
+ end
1120
+
1121
+ # Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
1122
+ # with strict path containment so a crafted path cannot escape the dir.
1123
+ private def serve_webui_ext(req, res)
1124
+ rel = req.path.delete_prefix("/webui_ext/")
1125
+ abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
1126
+
1127
+ unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
1128
+ res.status = 404
1129
+ res.body = "not found"
1130
+ return
1131
+ end
1132
+
1133
+ ext = File.extname(abs)
1134
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1135
+ ".html" => "text/html; charset=utf-8" }[ext]
1136
+ unless ctype
1137
+ res.status = 415
1138
+ res.body = "unsupported media type"
1139
+ return
1140
+ end
1141
+
1142
+ res.status = 200
1143
+ res["Content-Type"] = ctype
1144
+ res["Cache-Control"] = "no-store"
1145
+ res["Pragma"] = "no-cache"
1146
+ res.body = File.read(abs)
1147
+ end
1148
+
1149
+ # Serve agents/<name>/webui/<file> from built-in or user agent dir.
1150
+ # Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
1151
+ private def serve_agent_ui(req, res)
1152
+ rest = req.path.delete_prefix("/agent_ui/")
1153
+ name, _, rel = rest.partition("/")
1154
+ dir = agent_profile_dirs[name]
1155
+ return (res.status = 404; res.body = "not found") unless dir && !rel.empty?
1156
+
1157
+ serve_static_under(File.join(dir, "webui"), rel, res)
1158
+ end
1159
+
1160
+ # Serve official panel assets: /panel_ui/<panel>/<rel>.
1161
+ private def serve_panel_ui(req, res)
1162
+ rest = req.path.delete_prefix("/panel_ui/")
1163
+ panel, _, rel = rest.partition("/")
1164
+ return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
1165
+
1166
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
1167
+ serve_static_under(root, rel, res)
1168
+ end
1169
+
1170
+ # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1171
+ # strict path containment so a crafted rel cannot escape `root`.
1172
+ private def serve_static_under(root, rel, res)
1173
+ root = File.expand_path(root)
1174
+ abs = File.expand_path(File.join(root, rel))
1175
+ unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
1176
+ res.status = 404
1177
+ res.body = "not found"
1178
+ return
1179
+ end
1180
+
1181
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1182
+ ".html" => "text/html; charset=utf-8" }[File.extname(abs)]
1183
+ unless ctype
1184
+ res.status = 415
1185
+ res.body = "unsupported media type"
1186
+ return
1187
+ end
1188
+
1189
+ res.status = 200
1190
+ res["Content-Type"] = ctype
1191
+ res["Cache-Control"] = "no-store"
1192
+ res["Pragma"] = "no-cache"
1193
+ res.body = File.read(abs)
1194
+ end
955
1195
  def api_browser_status(res)
956
1196
  json_response(res, 200, @browser_manager.status)
957
1197
  end
@@ -1060,13 +1300,18 @@ module Clacky
1060
1300
 
1061
1301
  aspect_ratio = body["aspect_ratio"].to_s
1062
1302
  aspect_ratio = "landscape" if aspect_ratio.empty?
1063
- output_dir = body["output_dir"].to_s
1064
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1303
+ output_dir = Clacky::Media::OutputDir.resolve(
1304
+ param: body["output_dir"],
1305
+ configured: @agent_config.media_output_dir,
1306
+ fallback: @agent_config.default_working_dir || Dir.pwd
1307
+ )
1065
1308
 
1066
1309
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1067
1310
  prompt: prompt,
1068
1311
  aspect_ratio: aspect_ratio,
1069
- output_dir: output_dir
1312
+ output_dir: output_dir,
1313
+ image: body["image"],
1314
+ images: body["images"]
1070
1315
  )
1071
1316
  if result["success"]
1072
1317
  log_media_usage(result, prompt: prompt)
@@ -1090,8 +1335,11 @@ module Clacky
1090
1335
  aspect_ratio = "landscape" if aspect_ratio.empty?
1091
1336
  duration = body["duration_seconds"]
1092
1337
  image = body["image"]
1093
- output_dir = body["output_dir"].to_s
1094
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1338
+ output_dir = Clacky::Media::OutputDir.resolve(
1339
+ param: body["output_dir"],
1340
+ configured: @agent_config.media_output_dir,
1341
+ fallback: @agent_config.default_working_dir || Dir.pwd
1342
+ )
1095
1343
 
1096
1344
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1097
1345
  prompt: prompt,
@@ -1119,8 +1367,11 @@ module Clacky
1119
1367
  end
1120
1368
 
1121
1369
  voice = body["voice"]
1122
- output_dir = body["output_dir"].to_s
1123
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1370
+ output_dir = Clacky::Media::OutputDir.resolve(
1371
+ param: body["output_dir"],
1372
+ configured: @agent_config.media_output_dir,
1373
+ fallback: @agent_config.default_working_dir || Dir.pwd
1374
+ )
1124
1375
 
1125
1376
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1126
1377
  input: input,
@@ -3369,7 +3620,147 @@ module Clacky
3369
3620
  json_response(res, 200, { skills: skill_data })
3370
3621
  end
3371
3622
 
3372
- # GET /api/sessions/:id/files?path=<relative dir>
3623
+ # GET /api/sessions/:id/git/<action> — read-only git info for the session's
3624
+ # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3625
+ # log accepts ?limit=.
3626
+ def api_session_git(session_id, action, req, res)
3627
+ dir = git_session_dir(session_id, res)
3628
+ return unless dir
3629
+
3630
+ unless Clacky::Server::GitPanel.repo?(dir)
3631
+ return json_response(res, 200, { repo: false })
3632
+ end
3633
+
3634
+ query = URI.decode_www_form(req.query_string.to_s).to_h
3635
+ case action
3636
+ when "status"
3637
+ json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
3638
+ when "diff"
3639
+ json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
3640
+ when "log"
3641
+ json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
3642
+ when "branches"
3643
+ json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
3644
+ else
3645
+ json_response(res, 404, { error: "Unknown git action" })
3646
+ end
3647
+ end
3648
+
3649
+ # POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
3650
+ def api_session_git_commit(session_id, req, res)
3651
+ dir = git_session_dir(session_id, res)
3652
+ return unless dir
3653
+
3654
+ unless Clacky::Server::GitPanel.repo?(dir)
3655
+ return json_response(res, 400, { error: "Not a git repository" })
3656
+ end
3657
+
3658
+ body = parse_json_body(req)
3659
+ result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
3660
+ if result[:ok]
3661
+ json_response(res, 200, result)
3662
+ else
3663
+ json_response(res, 422, { error: result[:error] })
3664
+ end
3665
+ end
3666
+
3667
+ # GET /api/sessions/:id/time_machine — task history for the Time Machine
3668
+ # panel. Mirrors the CLI menu: each entry carries id, summary, status
3669
+ # (current/past/undone) and whether it branches.
3670
+ def api_session_time_machine(session_id, res)
3671
+ agent = time_machine_agent(session_id, res)
3672
+ return unless agent
3673
+
3674
+ history = agent.get_task_history(limit: 20)
3675
+ json_response(res, 200, { tasks: history })
3676
+ end
3677
+
3678
+ # POST /api/sessions/:id/time_machine/switch — body: { task_id: }.
3679
+ # Restores the working tree to the end-of-task state of task_id.
3680
+ def api_session_time_machine_switch(session_id, req, res)
3681
+ agent = time_machine_agent(session_id, res)
3682
+ return unless agent
3683
+
3684
+ body = parse_json_body(req)
3685
+ task_id = body["task_id"].to_i
3686
+ result = agent.switch_to_task(task_id)
3687
+ if result[:success]
3688
+ @session_manager.save(agent.to_session_data(status: :success))
3689
+ broadcast_session_update(session_id)
3690
+ json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
3691
+ else
3692
+ json_response(res, 422, { ok: false, error: result[:message] })
3693
+ end
3694
+ end
3695
+
3696
+ # GET /api/sessions/:id/time_machine/:task_id/diff
3697
+ # Without ?path: returns the file list this task touched.
3698
+ # With ?path=<rel>: returns the unified diff of that file.
3699
+ def api_session_time_machine_diff(session_id, task_id, req, res)
3700
+ agent = time_machine_agent(session_id, res)
3701
+ return unless agent
3702
+
3703
+ rel = req.query["path"].to_s
3704
+ if rel.empty?
3705
+ json_response(res, 200, { ok: true, task_id: task_id, files: agent.task_diff_files(task_id) })
3706
+ else
3707
+ diff = agent.task_file_diff(task_id, rel)
3708
+ if diff.nil?
3709
+ json_response(res, 404, { ok: false, error: "No diff for #{rel}" })
3710
+ else
3711
+ json_response(res, 200, { ok: true, task_id: task_id }.merge(diff))
3712
+ end
3713
+ end
3714
+ end
3715
+
3716
+ # GET /api/sessions/:id/time_machine/:task_id/restore_preview
3717
+ # Returns the file-level effect of switching back to this task without
3718
+ # actually performing the switch. Lets the UI render an honest
3719
+ # confirmation listing the files that would be overwritten/created/deleted.
3720
+ def api_session_time_machine_restore_preview(session_id, task_id, res)
3721
+ agent = time_machine_agent(session_id, res)
3722
+ return unless agent
3723
+
3724
+ changes = agent.preview_restore_to_task(task_id)
3725
+ json_response(res, 200, { ok: true, task_id: task_id, changes: changes })
3726
+ end
3727
+
3728
+ # Resolve a session's agent for time-machine ops; writes the error
3729
+ # response and returns nil on failure.
3730
+ private def time_machine_agent(session_id, res)
3731
+ unless @registry.ensure(session_id)
3732
+ json_response(res, 404, { error: "Session not found" })
3733
+ return nil
3734
+ end
3735
+ session = @registry.get(session_id)
3736
+ agent = session && session[:agent]
3737
+ unless agent
3738
+ json_response(res, 404, { error: "Session not found" })
3739
+ return nil
3740
+ end
3741
+ agent
3742
+ end
3743
+
3744
+ # Resolve a session's working_dir for git ops; writes the error response
3745
+ # and returns nil on any failure.
3746
+ private def git_session_dir(session_id, res)
3747
+ unless @registry.ensure(session_id)
3748
+ json_response(res, 404, { error: "Session not found" })
3749
+ return nil
3750
+ end
3751
+ session = @registry.get(session_id)
3752
+ agent = session && session[:agent]
3753
+ unless agent
3754
+ json_response(res, 404, { error: "Session not found" })
3755
+ return nil
3756
+ end
3757
+ dir = File.expand_path(agent.working_dir.to_s)
3758
+ unless Dir.exist?(dir)
3759
+ json_response(res, 404, { error: "Working directory not found" })
3760
+ return nil
3761
+ end
3762
+ dir
3763
+ end
3373
3764
  # Lists one directory level inside the session's working_dir (lazy, per-layer).
3374
3765
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
3375
3766
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
@@ -4347,7 +4738,83 @@ module Clacky
4347
4738
  json_response(res, 422, { error: e.message })
4348
4739
  end
4349
4740
 
4350
- # POST /api/config — save updated model list
4741
+ # GET /api/config/media-output-dir
4742
+ # Returns the user-configured directory under which generated media
4743
+ # files (images / videos / audio) are persisted, plus the default
4744
+ # the system would use if the value is empty. The frontend uses
4745
+ # `default` as a placeholder hint.
4746
+ def api_get_media_output_dir(res)
4747
+ json_response(res, 200, {
4748
+ ok: true,
4749
+ value: @agent_config.media_output_dir.to_s,
4750
+ default: default_media_output_dir
4751
+ })
4752
+ end
4753
+
4754
+ # PATCH /api/config/media-output-dir
4755
+ # Body: { "value": "<absolute or ~-prefixed path, or empty to clear>" }
4756
+ # Empty / blank value clears the override, restoring the legacy
4757
+ # fallback (default_working_dir → Dir.pwd) for new generations.
4758
+ def api_update_media_output_dir(req, res)
4759
+ body = parse_json_body(req)
4760
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
4761
+
4762
+ raw = body["value"].to_s.strip
4763
+ if raw.empty?
4764
+ @agent_config.media_output_dir = nil
4765
+ @agent_config.save
4766
+ return json_response(res, 200, {
4767
+ ok: true,
4768
+ value: "",
4769
+ default: default_media_output_dir
4770
+ })
4771
+ end
4772
+
4773
+ expanded = File.expand_path(raw)
4774
+
4775
+ # Reject anything that's not an absolute path after `~` expansion.
4776
+ # Relative paths would silently resolve against the server's CWD,
4777
+ # which is exactly the source of confusion this setting exists to fix.
4778
+ unless expanded.start_with?("/")
4779
+ return json_response(res, 422, {
4780
+ error: "media_output_dir must be an absolute path or start with ~"
4781
+ })
4782
+ end
4783
+
4784
+ # Create the directory if missing; surface filesystem errors plainly
4785
+ # so the user can fix permissions / typo without reading server logs.
4786
+ begin
4787
+ FileUtils.mkdir_p(expanded)
4788
+ rescue Errno::EACCES, Errno::EROFS, Errno::ENOSPC, Errno::ENOTDIR => e
4789
+ return json_response(res, 422, {
4790
+ error: "cannot create directory: #{e.message}"
4791
+ })
4792
+ end
4793
+
4794
+ unless File.writable?(expanded)
4795
+ return json_response(res, 422, {
4796
+ error: "directory is not writable: #{expanded}"
4797
+ })
4798
+ end
4799
+
4800
+ @agent_config.media_output_dir = expanded
4801
+ @agent_config.save
4802
+ json_response(res, 200, {
4803
+ ok: true,
4804
+ value: expanded,
4805
+ default: default_media_output_dir
4806
+ })
4807
+ rescue => e
4808
+ json_response(res, 422, { error: e.message })
4809
+ end
4810
+
4811
+ # The path the resolver would pick if media_output_dir is blank.
4812
+ # Mirrors the fallback chain inside Clacky::Media::OutputDir.resolve so
4813
+ # the frontend can render it as a placeholder hint (no second source
4814
+ # of truth — both call sites read default_working_dir).
4815
+ private def default_media_output_dir
4816
+ @agent_config.default_working_dir.to_s.empty? ? Dir.pwd : @agent_config.default_working_dir
4817
+ end
4351
4818
  # DEPRECATED: this endpoint previously accepted the entire models array
4352
4819
  # and replaced @models in place. That design was fragile — any missing
4353
4820
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -5085,6 +5552,10 @@ module Clacky
5085
5552
  conn.send_json(type: "error", message: "Session not found: #{session_id}")
5086
5553
  end
5087
5554
 
5555
+ when "edit_message"
5556
+ session_id = msg["session_id"] || conn.session_id
5557
+ handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
5558
+
5088
5559
  when "message"
5089
5560
  session_id = msg["session_id"] || conn.session_id
5090
5561
  # Merge legacy images array into files as { data_url:, name:, mime_type: } entries
@@ -5135,6 +5606,20 @@ module Clacky
5135
5606
 
5136
5607
  # ── Session actions ───────────────────────────────────────────────────────
5137
5608
 
5609
+ def handle_edit_message(session_id, content, created_at)
5610
+ return unless @registry.exist?(session_id)
5611
+
5612
+ agent = nil
5613
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
5614
+ return unless agent
5615
+
5616
+ if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
5617
+ agent.history.truncate_from_created_at(created_at)
5618
+ end
5619
+
5620
+ handle_user_message(session_id, content)
5621
+ end
5622
+
5138
5623
  def handle_user_message(session_id, content, files = [])
5139
5624
  return unless @registry.exist?(session_id)
5140
5625
 
@@ -36,6 +36,7 @@ module Clacky
36
36
  @worker_pid = nil
37
37
  @restart_requested = false
38
38
  @shutdown_requested = false
39
+ @shutdown_signal = nil
39
40
  end
40
41
 
41
42
  def run
@@ -68,9 +69,9 @@ module Clacky
68
69
 
69
70
  # 3. Signal handlers
70
71
  Signal.trap("USR1") { @restart_requested = true }
71
- Signal.trap("TERM") { @shutdown_requested = true }
72
- Signal.trap("INT") { @shutdown_requested = true }
73
- Signal.trap("HUP") { @shutdown_requested = true }
72
+ Signal.trap("TERM") { @shutdown_signal = "TERM"; @shutdown_requested = true }
73
+ Signal.trap("INT") { @shutdown_signal = "INT"; @shutdown_requested = true }
74
+ Signal.trap("HUP") { @shutdown_signal = "HUP"; @shutdown_requested = true }
74
75
 
75
76
  # 4. Spawn first worker
76
77
  @worker_pid = spawn_worker
@@ -184,7 +185,8 @@ module Clacky
184
185
  end
185
186
 
186
187
  def shutdown
187
- Clacky::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid})...")
188
+ reason = @shutdown_signal ? "signal=SIG#{@shutdown_signal}" : "signal=none"
189
+ Clacky::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid}) #{reason}...")
188
190
  if @worker_pid
189
191
  begin
190
192
  # TERM the entire worker process group so grandchildren (node MCP, etc.)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.2"
4
+ VERSION = "1.3.3"
5
5
  end