openclacky 1.1.6 → 1.2.0

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -89,6 +89,11 @@ module Clacky
89
89
  @events << { type: "token_usage", session_id: @session_id }.merge(token_data)
90
90
  end
91
91
 
92
+ def show_feedback_request(question, context, options)
93
+ @events << { type: "request_feedback", session_id: @session_id,
94
+ question: question, context: context, options: options }
95
+ end
96
+
92
97
  # Ignore all other UI methods (progress, errors, etc.) during history replay
93
98
  def method_missing(name, *args, **kwargs); end
94
99
  def respond_to_missing?(name, include_private = false); true; end
@@ -154,8 +159,8 @@ module Clacky
154
159
  @agent_config = agent_config
155
160
  @client_factory = client_factory # callable: -> { Clacky::Client.new(...) }
156
161
  @brand_test = brand_test # when true, skip remote API calls for license activation
157
- @inherited_socket = socket # TCPServer socket passed from Master (nil = standalone mode)
158
- @master_pid = master_pid # Master PID so we can send USR1 on upgrade/restart
162
+ @inherited_socket = socket # TCPServer socket passed from Master (nil = standalone mode)
163
+ @master_pid = master_pid # Master PID so we can send USR1 on upgrade/restart
159
164
  # Capture the absolute path of the entry script and original ARGV at startup,
160
165
  # so api_restart can re-exec the correct binary even if cwd changes later.
161
166
  @restart_script = File.expand_path($0)
@@ -186,6 +191,9 @@ module Clacky
186
191
  )
187
192
  @browser_manager = Clacky::BrowserManager.instance
188
193
  @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.load)
194
+ # Lazy: process-wide MCP registry. Created on first /api/mcp/:name access
195
+ # so test setups that override Dir.home in before-hooks still work.
196
+ @mcp_registry_mutex = Mutex.new
189
197
  # Access key authentication:
190
198
  # - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
191
199
  # - Any other bind address requires CLACKY_ACCESS_KEY env var.
@@ -201,6 +209,9 @@ module Clacky
201
209
  end
202
210
 
203
211
  def start
212
+ # One-time migration: move legacy trash contents into file-trash/ subdirectory.
213
+ Clacky::TrashDirectory.migrate_legacy_if_needed
214
+
204
215
  # Enable console logging for the server process so log lines are visible in the terminal.
205
216
  Clacky::Logger.console = true
206
217
 
@@ -240,13 +251,13 @@ module Clacky
240
251
  shutdown_proc = proc do
241
252
  next if shutdown_once
242
253
  shutdown_once = true
243
- Thread.new do
244
- sleep 2
245
- Clacky::Logger.warn("[HttpServer] Forced exit after graceful shutdown timeout.")
246
- exit!(0)
247
- end
248
- # Detach the inherited (shared) listen socket BEFORE shutdown so that
249
- # WEBrick's cleanup_listener does not call shutdown(SHUT_RDWR)+close on
254
+ # Persist in-flight agent sessions BEFORE starting the forced-exit
255
+ # timer, so any new messages added to @history since the last save
256
+ # are on disk before the new worker reads them after a hot restart.
257
+ interrupt_all_agents
258
+
259
+ # Detach the inherited (shared) listen socket BEFORE WEBrick.shutdown
260
+ # so that cleanup_listener does not call shutdown(SHUT_RDWR)+close on
250
261
  # it — that would propagate to every process sharing the underlying
251
262
  # kernel socket (Master + new worker), breaking subsequent accept()
252
263
  # on Linux. macOS's BSD stack tolerates this; Linux does not.
@@ -256,8 +267,10 @@ module Clacky
256
267
  end
257
268
  t1 = Thread.new { @channel_manager.stop rescue nil }
258
269
  t2 = Thread.new { Clacky::BrowserManager.instance.stop rescue nil }
270
+ t3 = Thread.new { @mcp_registry&.shutdown rescue nil }
259
271
  t1.join(1.5)
260
272
  t2.join(1.5)
273
+ t3.join(1.5)
261
274
  server.shutdown rescue nil
262
275
  end
263
276
  trap("INT") { shutdown_proc.call }
@@ -380,6 +393,8 @@ module Clacky
380
393
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
381
394
  when ["GET", "/api/skills"] then api_list_skills(res)
382
395
  when ["GET", "/api/config"] then api_get_config(res)
396
+ when ["GET", "/api/config/settings"] then api_get_settings(res)
397
+ when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
383
398
  when ["POST", "/api/config/models"] then api_add_model(req, res)
384
399
  when ["POST", "/api/config/test"] then api_test_config(req, res)
385
400
  when ["GET", "/api/providers"] then api_list_providers(res)
@@ -400,11 +415,15 @@ module Clacky
400
415
  when ["GET", "/api/trash"] then api_trash(req, res)
401
416
  when ["POST", "/api/trash/restore"] then api_trash_restore(req, res)
402
417
  when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
418
+ when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
419
+ when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
420
+ when ["DELETE", "/api/trash/sessions"] then api_trash_sessions_delete(req, res)
403
421
  when ["GET", "/api/profile"] then api_profile_get(res)
404
422
  when ["PUT", "/api/profile"] then api_profile_put(req, res)
405
423
  when ["GET", "/api/memories"] then api_memories_list(res)
406
424
  when ["POST", "/api/memories"] then api_memories_create(req, res)
407
425
  when ["GET", "/api/channels"] then api_list_channels(res)
426
+ when ["GET", "/api/mcp"] then api_mcp_list(res)
408
427
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
409
428
  when ["POST", "/api/upload"] then api_upload_file(req, res)
410
429
  when ["POST", "/api/file-action"] then api_file_action(req, res)
@@ -412,6 +431,9 @@ module Clacky
412
431
  when ["GET", "/api/version"] then api_get_version(res)
413
432
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
414
433
  when ["POST", "/api/restart"] then api_restart(req, res)
434
+ when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
435
+ when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
436
+ when ["GET", "/api/billing/records"] then api_billing_records(req, res)
415
437
  when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
416
438
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
417
439
  else
@@ -433,6 +455,26 @@ module Clacky
433
455
  elsif method == "DELETE" && path.start_with?("/api/channels/")
434
456
  platform = path.sub("/api/channels/", "")
435
457
  api_delete_channel(platform, res)
458
+ elsif method == "POST" && path.match?(%r{^/api/mcp/[^/]+/probe$})
459
+ name = path.sub("/api/mcp/", "").sub("/probe", "")
460
+ api_mcp_probe(name, res)
461
+ elsif method == "GET" && path.match?(%r{^/api/mcp/[^/]+/tools$})
462
+ name = path.sub("/api/mcp/", "").sub("/tools", "")
463
+ api_mcp_tools(name, res)
464
+ elsif method == "POST" && path.match?(%r{^/api/mcp/[^/]+/call$})
465
+ name = path.sub("/api/mcp/", "").sub("/call", "")
466
+ api_mcp_call(name, req, res)
467
+ elsif method == "POST" && path == "/api/mcp"
468
+ api_mcp_create(req, res)
469
+ elsif method == "PATCH" && path.match?(%r{^/api/mcp/[^/]+/enabled$})
470
+ name = path.sub("/api/mcp/", "").sub("/enabled", "")
471
+ api_mcp_toggle(name, req, res)
472
+ elsif method == "PUT" && path.match?(%r{^/api/mcp/[^/]+$})
473
+ name = path.sub("/api/mcp/", "")
474
+ api_mcp_update(name, req, res)
475
+ elsif method == "DELETE" && path.match?(%r{^/api/mcp/[^/]+$})
476
+ name = path.sub("/api/mcp/", "")
477
+ api_mcp_delete(name, req, res)
436
478
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
437
479
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
438
480
  api_session_skills(session_id, res)
@@ -460,6 +502,9 @@ module Clacky
460
502
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
461
503
  session_id = path.sub("/api/sessions/", "")
462
504
  api_delete_session(session_id, res)
505
+ elsif method == "DELETE" && path.match?(%r{^/api/trash/sessions/[^/]+$})
506
+ session_id = path.sub("/api/trash/sessions/", "")
507
+ api_trash_session_delete_one(session_id, res)
463
508
  elsif method == "POST" && path.match?(%r{^/api/config/models/[^/]+/default$})
464
509
  id = path.sub("/api/config/models/", "").sub("/default", "")
465
510
  api_set_default_model(id, res)
@@ -1062,6 +1107,58 @@ module Clacky
1062
1107
 
1063
1108
  # ── Version API ───────────────────────────────────────────────────────────
1064
1109
 
1110
+ # ── Billing API ────────────────────────────────────────────────────────────
1111
+
1112
+ # GET /api/billing/summary
1113
+ # Returns billing summary for a time period
1114
+ # Query params: period (day|week|month|year|all, default: month)
1115
+ def api_billing_summary(req, res)
1116
+ require_relative "../billing/billing_store"
1117
+
1118
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1119
+ period = (query["period"] || "month").to_sym
1120
+
1121
+ store = Clacky::Billing::BillingStore.new
1122
+ summary = store.summary(period: period)
1123
+
1124
+ json_response(res, 200, summary)
1125
+ end
1126
+
1127
+ # GET /api/billing/daily
1128
+ # Returns daily cost breakdown
1129
+ # Query params: days (default: 30)
1130
+ def api_billing_daily(req, res)
1131
+ require_relative "../billing/billing_store"
1132
+
1133
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1134
+ days = [(query["days"] || "30").to_i, 90].min
1135
+
1136
+ store = Clacky::Billing::BillingStore.new
1137
+ daily = store.daily_breakdown(days: days)
1138
+
1139
+ json_response(res, 200, { days: daily })
1140
+ end
1141
+
1142
+ # GET /api/billing/records
1143
+ # Returns recent billing records
1144
+ # Query params: limit (default: 100), model, session_id
1145
+ def api_billing_records(req, res)
1146
+ require_relative "../billing/billing_store"
1147
+
1148
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1149
+ limit = [(query["limit"] || "100").to_i, 500].min
1150
+ model = query["model"]
1151
+ session_id = query["session_id"]
1152
+
1153
+ store = Clacky::Billing::BillingStore.new
1154
+ records = store.query(model: model, session_id: session_id, limit: limit)
1155
+
1156
+ json_response(res, 200, {
1157
+ records: records.map(&:to_h),
1158
+ count: records.size
1159
+ })
1160
+ end
1161
+
1065
1162
  # GET /api/version
1066
1163
  # Returns current version and latest version from RubyGems (cached for 1 hour).
1067
1164
  def api_get_version(res)
@@ -1508,6 +1605,270 @@ module Clacky
1508
1605
  json_response(res, 200, { channels: platforms })
1509
1606
  end
1510
1607
 
1608
+ # GET /api/mcp
1609
+ # Lists configured MCP servers without spawning any subprocess. Honors
1610
+ # both ~/.clacky/mcp.json (global) and project-level overrides.
1611
+ def api_mcp_list(res)
1612
+ data = mcp_load_raw_config
1613
+ servers = (data["mcpServers"] || {}).map do |name, spec|
1614
+ next nil unless spec.is_a?(Hash)
1615
+
1616
+ type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
1617
+ {
1618
+ name: name.to_s,
1619
+ type: type,
1620
+ description: spec["description"] || "",
1621
+ command: spec["command"],
1622
+ args: Array(spec["args"]),
1623
+ url: spec["url"],
1624
+ disabled: spec["disabled"] == true,
1625
+ has_env: spec["env"].is_a?(Hash) && !spec["env"].empty?,
1626
+ has_headers: spec["headers"].is_a?(Hash) && !spec["headers"].empty?,
1627
+ }
1628
+ end.compact
1629
+
1630
+ json_response(res, 200, {
1631
+ configured: !servers.empty?,
1632
+ config_path: mcp_config_path,
1633
+ config_exists: File.exist?(mcp_config_path),
1634
+ servers: servers,
1635
+ })
1636
+ end
1637
+
1638
+ # POST /api/mcp/:name/probe
1639
+ # Spawns the MCP server briefly to fetch its tool catalog, then shuts it
1640
+ # down. Used by the WebUI to display each server's tool list on demand.
1641
+ # No state survives the request — the next agent run does its own lazy spawn.
1642
+ def api_mcp_probe(name, res)
1643
+ registry = Clacky::Mcp::Registry.new(idle_timeout: 0)
1644
+ unless registry.configured?(name)
1645
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found in mcp.json" })
1646
+ return
1647
+ end
1648
+
1649
+ tools = registry.tool_definitions(name).map do |defn|
1650
+ fn = defn[:function] || defn["function"] || {}
1651
+ {
1652
+ name: fn[:name] || fn["name"],
1653
+ description: fn[:description] || fn["description"] || "",
1654
+ input_schema: fn[:parameters] || fn["parameters"] || {},
1655
+ }
1656
+ end
1657
+
1658
+ json_response(res, 200, { ok: true, name: name, tools: tools, tool_count: tools.length })
1659
+ rescue Clacky::Mcp::Client::McpError, Clacky::Mcp::Client::TransportError => e
1660
+ json_response(res, 502, { ok: false, error: e.message })
1661
+ rescue StandardError => e
1662
+ json_response(res, 500, { ok: false, error: e.message })
1663
+ ensure
1664
+ registry&.shutdown
1665
+ end
1666
+
1667
+ # GET /api/mcp/:name/tools
1668
+ # Returns the live tool catalog for an MCP server, using the process-wide
1669
+ # registry. The first call cold-starts the server; later calls hit cache.
1670
+ # Subagents use this as a discovery endpoint, replacing the deleted
1671
+ # mcp_call tool's hidden tool list.
1672
+ def api_mcp_tools(name, res)
1673
+ unless mcp_registry.configured?(name)
1674
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found in mcp.json" })
1675
+ return
1676
+ end
1677
+
1678
+ tools = mcp_registry.tool_definitions(name).map do |defn|
1679
+ fn = defn[:function] || defn["function"] || {}
1680
+ {
1681
+ name: fn[:name] || fn["name"],
1682
+ description: fn[:description] || fn["description"] || "",
1683
+ input_schema: fn[:parameters] || fn["parameters"] || {},
1684
+ }
1685
+ end
1686
+
1687
+ json_response(res, 200, { ok: true, name: name, tools: tools, tool_count: tools.length })
1688
+ rescue Clacky::Mcp::Client::McpError, Clacky::Mcp::Client::TransportError => e
1689
+ json_response(res, 502, { ok: false, error: e.message })
1690
+ rescue StandardError => e
1691
+ json_response(res, 500, { ok: false, error: e.message })
1692
+ end
1693
+
1694
+ # POST /api/mcp/:name/call body: { tool: "...", arguments: {...} }
1695
+ # Forwards a tools/call to the configured MCP server and returns its raw
1696
+ # result. Subagents call this from their shell tool via curl — there is
1697
+ # no Ruby-side bridge tool anymore.
1698
+ def api_mcp_call(name, req, res)
1699
+ unless mcp_registry.configured?(name)
1700
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found in mcp.json" })
1701
+ return
1702
+ end
1703
+
1704
+ body = parse_json_body(req) || {}
1705
+ tool = body["tool"] || body[:tool]
1706
+ arguments = body["arguments"] || body[:arguments] || {}
1707
+
1708
+ if tool.nil? || tool.to_s.strip.empty?
1709
+ json_response(res, 400, { ok: false, error: "missing required field: tool" })
1710
+ return
1711
+ end
1712
+
1713
+ result = mcp_registry.call_tool(name, tool, arguments)
1714
+ json_response(res, 200, { ok: true, result: result })
1715
+ rescue Clacky::Mcp::Client::McpError, Clacky::Mcp::Client::TransportError => e
1716
+ json_response(res, 502, { ok: false, error: e.message })
1717
+ rescue StandardError => e
1718
+ json_response(res, 500, { ok: false, error: e.message })
1719
+ end
1720
+
1721
+ private def mcp_config_path
1722
+ File.join(Dir.home, ".clacky", "mcp.json")
1723
+ end
1724
+
1725
+ private def mcp_registry
1726
+ @mcp_registry_mutex.synchronize do
1727
+ @mcp_registry ||= Clacky::Mcp::Registry.new(working_dir: nil)
1728
+ end
1729
+ end
1730
+
1731
+ private def mcp_localhost_only(req, res)
1732
+ ip = req.peeraddr.last rescue nil
1733
+ return true if %w[127.0.0.1 ::1].include?(ip)
1734
+
1735
+ json_response(res, 403, { ok: false, error: "MCP write operations are only allowed from localhost" })
1736
+ false
1737
+ end
1738
+
1739
+ private def mcp_load_raw_config
1740
+ return { "mcpServers" => {} } unless File.exist?(mcp_config_path)
1741
+
1742
+ data = JSON.parse(File.read(mcp_config_path))
1743
+ data["mcpServers"] ||= data.delete("servers") || {}
1744
+ data
1745
+ rescue JSON::ParserError
1746
+ { "mcpServers" => {} }
1747
+ end
1748
+
1749
+ private def mcp_write_raw_config(data)
1750
+ FileUtils.mkdir_p(File.dirname(mcp_config_path))
1751
+ File.write(mcp_config_path, JSON.pretty_generate(data) + "\n")
1752
+ end
1753
+
1754
+ private def mcp_validate_spec(body)
1755
+ name = body["name"].to_s.strip
1756
+ return [nil, nil, "name is required"] if name.empty?
1757
+ return [nil, nil, "name contains invalid characters"] unless name.match?(/\A[A-Za-z0-9_\-]+\z/)
1758
+
1759
+ type = (body["type"] || (body["url"] ? "http" : "stdio")).to_s
1760
+ case type
1761
+ when "stdio"
1762
+ command = body["command"].to_s.strip
1763
+ return [nil, nil, "command is required"] if command.empty?
1764
+ spec = { "command" => command }
1765
+ spec["args"] = Array(body["args"]).map(&:to_s) if body["args"]
1766
+ spec["env"] = body["env"].transform_values(&:to_s) if body["env"].is_a?(Hash)
1767
+ spec["cwd"] = body["cwd"].to_s if body["cwd"].is_a?(String) && !body["cwd"].empty?
1768
+ when "http", "streamable-http"
1769
+ url = body["url"].to_s.strip
1770
+ return [nil, nil, "url is required for http type"] if url.empty?
1771
+ return [nil, nil, "url must be http(s)"] unless url.match?(%r{\Ahttps?://}i)
1772
+ spec = { "type" => "http", "url" => url }
1773
+ spec["headers"] = body["headers"].transform_values(&:to_s) if body["headers"].is_a?(Hash)
1774
+ else
1775
+ return [nil, nil, "unsupported type '#{type}' (use stdio or http)"]
1776
+ end
1777
+
1778
+ spec["description"] = body["description"].to_s if body["description"].is_a?(String) && !body["description"].empty?
1779
+ [name, spec, nil]
1780
+ end
1781
+
1782
+ # POST /api/mcp { name, command, args[], env{}, cwd?, description? }
1783
+ def api_mcp_create(req, res)
1784
+ return unless mcp_localhost_only(req, res)
1785
+
1786
+ body = parse_json_body(req)
1787
+ name, spec, err = mcp_validate_spec(body)
1788
+ if err
1789
+ json_response(res, 400, { ok: false, error: err })
1790
+ return
1791
+ end
1792
+
1793
+ data = mcp_load_raw_config
1794
+ if data["mcpServers"].key?(name)
1795
+ json_response(res, 409, { ok: false, error: "MCP server '#{name}' already exists. Use PUT to update." })
1796
+ return
1797
+ end
1798
+
1799
+ data["mcpServers"][name] = spec
1800
+ mcp_write_raw_config(data)
1801
+ json_response(res, 200, { ok: true, name: name, config_path: mcp_config_path })
1802
+ end
1803
+
1804
+ # PUT /api/mcp/:name { command, args[], env{}, cwd?, description? }
1805
+ # Replaces the entire spec. Path :name wins over body name.
1806
+ def api_mcp_update(name, req, res)
1807
+ return unless mcp_localhost_only(req, res)
1808
+
1809
+ body = parse_json_body(req).merge("name" => name)
1810
+ _, spec, err = mcp_validate_spec(body)
1811
+ if err
1812
+ json_response(res, 400, { ok: false, error: err })
1813
+ return
1814
+ end
1815
+
1816
+ data = mcp_load_raw_config
1817
+ unless data["mcpServers"].key?(name)
1818
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found" })
1819
+ return
1820
+ end
1821
+
1822
+ data["mcpServers"][name] = spec
1823
+ mcp_write_raw_config(data)
1824
+ json_response(res, 200, { ok: true, name: name })
1825
+ end
1826
+
1827
+ # DELETE /api/mcp/:name
1828
+ def api_mcp_delete(name, req, res)
1829
+ return unless mcp_localhost_only(req, res)
1830
+
1831
+ data = mcp_load_raw_config
1832
+ unless data["mcpServers"].key?(name)
1833
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found" })
1834
+ return
1835
+ end
1836
+
1837
+ data["mcpServers"].delete(name)
1838
+ mcp_write_raw_config(data)
1839
+ json_response(res, 200, { ok: true, name: name })
1840
+ end
1841
+
1842
+ # PATCH /api/mcp/:name/enabled body: { enabled: true|false }
1843
+ def api_mcp_toggle(name, req, res)
1844
+ return unless mcp_localhost_only(req, res)
1845
+
1846
+ body = parse_json_body(req) || {}
1847
+ enabled = body["enabled"]
1848
+ if enabled.nil? || ![true, false].include?(enabled)
1849
+ json_response(res, 400, { ok: false, error: "enabled (boolean) is required" })
1850
+ return
1851
+ end
1852
+
1853
+ data = mcp_load_raw_config
1854
+ spec = data["mcpServers"][name]
1855
+ unless spec.is_a?(Hash)
1856
+ json_response(res, 404, { ok: false, error: "MCP server '#{name}' not found" })
1857
+ return
1858
+ end
1859
+
1860
+ if enabled
1861
+ spec.delete("disabled")
1862
+ else
1863
+ spec["disabled"] = true
1864
+ end
1865
+ mcp_write_raw_config(data)
1866
+
1867
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1868
+
1869
+ json_response(res, 200, { ok: true, name: name, disabled: spec["disabled"] == true })
1870
+ end
1871
+
1511
1872
  # POST /api/channels/:platform/send
1512
1873
  # Proactively send a message to a user via the given IM platform.
1513
1874
  #
@@ -2306,6 +2667,94 @@ module Clacky
2306
2667
  })
2307
2668
  end
2308
2669
 
2670
+ # ── Session trash endpoints ──────────────────────────────────────
2671
+
2672
+ # GET /api/trash/sessions
2673
+ # Lists all soft-deleted sessions in the session trash directory.
2674
+ private def api_trash_sessions(_req, res)
2675
+ sessions = @session_manager.list_trash_sessions
2676
+
2677
+ result = sessions.map do |s|
2678
+ {
2679
+ session_id: s[:session_id],
2680
+ name: s[:name] || s[:title] || s[:session_id],
2681
+ created_at: s[:created_at],
2682
+ updated_at: s[:updated_at],
2683
+ deleted_at: s[:deleted_at],
2684
+ total_tasks: s.dig(:stats, :total_tasks) || 0,
2685
+ file_size: s[:file_size] || 0,
2686
+ model: s[:model],
2687
+ working_dir: s[:working_dir]
2688
+ }
2689
+ end
2690
+
2691
+ total_size = result.sum { |s| s[:file_size] }
2692
+
2693
+ json_response(res, 200, {
2694
+ ok: true,
2695
+ sessions: result,
2696
+ count: result.size,
2697
+ total_size: total_size
2698
+ })
2699
+ end
2700
+
2701
+ # POST /api/trash/sessions/restore
2702
+ # Body: { session_id: "..." }
2703
+ # Restores a soft-deleted session back to the active sessions list.
2704
+ private def api_trash_session_restore(req, res)
2705
+ data = parse_json_body(req)
2706
+ session_id = data["session_id"].to_s.strip
2707
+
2708
+ if session_id.empty?
2709
+ json_response(res, 400, { ok: false, error: "session_id is required" })
2710
+ return
2711
+ end
2712
+
2713
+ unless @session_manager.restore_session(session_id)
2714
+ json_response(res, 404, { ok: false, error: "Session not found in trash: #{session_id}" })
2715
+ return
2716
+ end
2717
+
2718
+ # Load the restored session into the registry so it behaves like any
2719
+ # other live session (status, agent, snapshot all available).
2720
+ @registry.ensure(session_id)
2721
+ session = @registry.session_summary(session_id)
2722
+
2723
+ # Use broadcast_all because no client is subscribed to a session that
2724
+ # was just sitting in the trash — broadcast(session_id, …) would reach
2725
+ # zero recipients.
2726
+ broadcast_all(type: "session_restored", session: session)
2727
+
2728
+ json_response(res, 200, { ok: true, session: session })
2729
+ end
2730
+
2731
+ # DELETE /api/trash/sessions/:id
2732
+ # Permanently delete a single session from the trash.
2733
+ private def api_trash_session_delete_one(session_id, res)
2734
+ unless @session_manager.permanent_delete_trash_session(session_id)
2735
+ json_response(res, 404, { ok: false, error: "Session not found in trash: #{session_id}" })
2736
+ return
2737
+ end
2738
+
2739
+ json_response(res, 200, { ok: true, session_id: session_id })
2740
+ end
2741
+
2742
+ # DELETE /api/trash/sessions?days_old=N
2743
+ # Bulk: permanently delete sessions older than N days (default: 7).
2744
+ private def api_trash_sessions_delete(req, res)
2745
+ query = URI.decode_www_form(req.query_string.to_s).to_h
2746
+ days_old = query["days_old"].to_s.strip
2747
+ days_i = days_old.empty? ? 7 : days_old.to_i
2748
+
2749
+ deleted = @session_manager.cleanup_trash(days: days_i)
2750
+
2751
+ json_response(res, 200, {
2752
+ ok: true,
2753
+ deleted_count: deleted,
2754
+ days_old: days_i
2755
+ })
2756
+ end
2757
+
2309
2758
  # ── Trash helpers (private) ─────────────────────────────────────
2310
2759
  # Reads all metadata sidecars in `trash_dir` and returns enriched
2311
2760
  # file records. Silently skips sidecars whose payload file has
@@ -2746,6 +3195,37 @@ module Clacky
2746
3195
  })
2747
3196
  end
2748
3197
 
3198
+ # GET /api/config/settings — return advanced settings
3199
+ def api_get_settings(res)
3200
+ json_response(res, 200, {
3201
+ ok: true,
3202
+ enable_compression: @agent_config.enable_compression,
3203
+ enable_prompt_caching: @agent_config.enable_prompt_caching,
3204
+ memory_update_enabled: @agent_config.memory_update_enabled
3205
+ })
3206
+ end
3207
+
3208
+ # PATCH /api/config/settings — update advanced settings
3209
+ def api_update_settings(req, res)
3210
+ body = parse_json_body(req)
3211
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
3212
+
3213
+ if body.key?("enable_compression")
3214
+ @agent_config.enable_compression = !!body["enable_compression"]
3215
+ end
3216
+ if body.key?("enable_prompt_caching")
3217
+ @agent_config.enable_prompt_caching = !!body["enable_prompt_caching"]
3218
+ end
3219
+ if body.key?("memory_update_enabled")
3220
+ @agent_config.memory_update_enabled = !!body["memory_update_enabled"]
3221
+ end
3222
+
3223
+ @agent_config.save
3224
+ json_response(res, 200, { ok: true })
3225
+ rescue => e
3226
+ json_response(res, 422, { error: e.message })
3227
+ end
3228
+
2749
3229
  # POST /api/config — save updated model list
2750
3230
  # DEPRECATED: this endpoint previously accepted the entire models array
2751
3231
  # and replaced @models in place. That design was fragile — any missing
@@ -3239,8 +3719,8 @@ module Clacky
3239
3719
  # fine and no longer blocks the disk cleanup below.
3240
3720
  @registry.delete(session_id) if in_registry
3241
3721
 
3242
- # Always physically remove the persisted session file (+ chunks).
3243
- @session_manager.delete(session_id) if on_disk
3722
+ # Soft-delete: move session to trash instead of permanently destroying it.
3723
+ @session_manager.soft_delete(session_id) if on_disk
3244
3724
 
3245
3725
  # Notify any still-connected clients (mainly matters when the
3246
3726
  # session was live, but harmless otherwise).
@@ -3459,9 +3939,16 @@ module Clacky
3459
3939
  # If session is running, interrupt it first (mimics CLI behavior)
3460
3940
  if session[:status] == :running
3461
3941
  interrupt_session(session_id)
3462
- # Wait briefly for the thread to catch the interrupt and update status
3463
- # This ensures the agent loop exits cleanly before starting the new task
3464
- sleep 0.1
3942
+
3943
+ # Give the old thread a short window to exit cleanly.
3944
+ # In the common case it returns within milliseconds (Thread#raise
3945
+ # lands on a tight loop or LLM read). If it can't be reached in
3946
+ # time (e.g. blocked in a slow subagent syscall), we proceed anyway:
3947
+ # the agent's check_stale! checkpoints will refuse to mutate
3948
+ # history once the new thread takes over.
3949
+ old_thread = nil
3950
+ @registry.with_session(session_id) { |s| old_thread = s[:thread] }
3951
+ old_thread&.join(2)
3465
3952
  end
3466
3953
 
3467
3954
  agent = nil
@@ -3621,6 +4108,23 @@ module Clacky
3621
4108
  run_agent_task(session_id, agent) { agent.run(prompt) }
3622
4109
  end
3623
4110
 
4111
+ # Interrupt every running agent thread and persist its session state.
4112
+ private def interrupt_all_agents
4113
+ return unless @registry && @session_manager
4114
+
4115
+ @registry.each_live_agent do |id, agent, thread|
4116
+ next unless thread&.alive?
4117
+ begin
4118
+ thread.raise(Clacky::AgentInterrupted, "Worker shutting down")
4119
+ Clacky::Logger.info("[shutdown] interrupted session=#{id}")
4120
+ rescue => e
4121
+ Clacky::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
4122
+ end
4123
+ thread.join(2)
4124
+ @session_manager.save(agent.to_session_data(status: :interrupted))
4125
+ end
4126
+ end
4127
+
3624
4128
  # Run an agent task in a background thread, handling status updates,
3625
4129
  # session persistence, and idle compression timer lifecycle.
3626
4130
  # Yields to the caller to perform the actual agent.run call.
@@ -3742,7 +4246,7 @@ module Clacky
3742
4246
  # ── Helpers ───────────────────────────────────────────────────────────────
3743
4247
 
3744
4248
  def default_working_dir
3745
- File.expand_path("~/clacky_workspace")
4249
+ @agent_config&.default_working_dir || File.expand_path("~/clacky_workspace")
3746
4250
  end
3747
4251
 
3748
4252
  # Create a session in the registry and wire up Agent + WebUIController.