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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- 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
|
|
158
|
-
@master_pid
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
# Detach the inherited (shared) listen socket BEFORE shutdown
|
|
249
|
-
#
|
|
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
|
-
#
|
|
3243
|
-
@session_manager.
|
|
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
|
-
|
|
3463
|
-
#
|
|
3464
|
-
|
|
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.
|