openclacky 1.5.21 → 1.5.22
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 +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
|
@@ -75,26 +75,38 @@ module Clacky
|
|
|
75
75
|
ev[:skill_command_display] = skill_command_display if skill_command_display
|
|
76
76
|
ev[:editable] = false unless editable
|
|
77
77
|
ev[:references] = references unless Array(references).empty?
|
|
78
|
-
|
|
78
|
+
rendered_images = []
|
|
79
|
+
rendered_attachments = []
|
|
80
|
+
Array(files).each do |f|
|
|
81
|
+
next if f[:reference] || f["reference"]
|
|
82
|
+
|
|
79
83
|
url = f[:data_url] || f["data_url"]
|
|
80
84
|
name = f[:name] || f["name"]
|
|
81
85
|
path = f[:path] || f["path"]
|
|
82
86
|
type = f[:type] || f["type"] || ""
|
|
87
|
+
mime = f[:mime_type] || f["mime_type"]
|
|
88
|
+
image = type.to_s == "image" || mime.to_s.start_with?("image/")
|
|
83
89
|
|
|
84
90
|
if url
|
|
85
|
-
url
|
|
86
|
-
elsif
|
|
91
|
+
rendered_images << url
|
|
92
|
+
elsif image && path && File.exist?(path.to_s)
|
|
87
93
|
# Serve via the /api/local-image proxy instead of inlining a base64
|
|
88
94
|
# data URL. Inlining forced a synchronous disk-read + full base64
|
|
89
95
|
# encode + downscale on every history replay (2-3s lag for sessions
|
|
90
96
|
# with downgraded text-model images). The proxy lets the browser
|
|
91
97
|
# lazy-load + cache the image, keeping the replay response tiny.
|
|
92
|
-
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
98
|
+
rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
99
|
+
elsif image && name
|
|
100
|
+
rendered_images << "expired:#{name}"
|
|
93
101
|
elsif name
|
|
94
|
-
type.to_s
|
|
102
|
+
attachment_type = type.to_s.empty? ? (mime || "file") : type
|
|
103
|
+
attachment = { name: name, type: attachment_type }
|
|
104
|
+
attachment[:path] = path if path && File.exist?(path.to_s)
|
|
105
|
+
rendered_attachments << attachment
|
|
95
106
|
end
|
|
96
107
|
end
|
|
97
|
-
ev[:images] =
|
|
108
|
+
ev[:images] = rendered_images unless rendered_images.empty?
|
|
109
|
+
ev[:attachments] = rendered_attachments unless rendered_attachments.empty?
|
|
98
110
|
@events << ev
|
|
99
111
|
end
|
|
100
112
|
|
|
@@ -838,9 +850,6 @@ module Clacky
|
|
|
838
850
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
839
851
|
action = path[%r{/git/([a-z]+)$}, 1]
|
|
840
852
|
api_session_git(session_id, action, req, res)
|
|
841
|
-
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
842
|
-
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
843
|
-
api_session_git_commit(session_id, req, res)
|
|
844
853
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
|
|
845
854
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
846
855
|
api_session_git_restore(session_id, req, res)
|
|
@@ -960,6 +969,12 @@ module Clacky
|
|
|
960
969
|
elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
|
|
961
970
|
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
962
971
|
api_memories_delete(filename, res)
|
|
972
|
+
elsif method == "GET" && path.match?(%r{^/api/projects/[^/]+/git$})
|
|
973
|
+
project_id = path.sub("/api/projects/", "").sub("/git", "")
|
|
974
|
+
api_project_git(project_id, req, res)
|
|
975
|
+
elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/git/checkout$})
|
|
976
|
+
project_id = path.sub("/api/projects/", "").sub("/git/checkout", "")
|
|
977
|
+
api_project_git_checkout(project_id, req, res)
|
|
963
978
|
elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
|
|
964
979
|
project_id = path.sub("/api/projects/", "")
|
|
965
980
|
api_update_project(project_id, req, res)
|
|
@@ -2321,7 +2336,7 @@ module Clacky
|
|
|
2321
2336
|
json_response(res, 200, {
|
|
2322
2337
|
ok: true,
|
|
2323
2338
|
provider: config["provider"],
|
|
2324
|
-
|
|
2339
|
+
keys_masked: masked_search_keys(config),
|
|
2325
2340
|
available: Clacky::Utils::SearcherManager.available
|
|
2326
2341
|
})
|
|
2327
2342
|
end
|
|
@@ -2338,12 +2353,12 @@ module Clacky
|
|
|
2338
2353
|
return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
|
|
2339
2354
|
end
|
|
2340
2355
|
|
|
2341
|
-
key = body["key"].to_s
|
|
2342
|
-
key = Clacky::SearchConfig.key if key.include?("****")
|
|
2343
|
-
|
|
2344
2356
|
current = Clacky::SearchConfig.load
|
|
2345
2357
|
final_provider = body.key?("provider") ? provider : current["provider"]
|
|
2346
|
-
|
|
2358
|
+
# A masked value only ever stands for the same provider's stored key.
|
|
2359
|
+
key = body["key"].to_s
|
|
2360
|
+
key = Clacky::SearchConfig.key_for(final_provider) if key.include?("****")
|
|
2361
|
+
final_key = body.key?("key") ? key : Clacky::SearchConfig.key_for(final_provider)
|
|
2347
2362
|
|
|
2348
2363
|
# deepseek_web falls back to the configured model key, so it may be
|
|
2349
2364
|
# saved without one. Every other searcher is a script that reads the
|
|
@@ -2361,11 +2376,15 @@ module Clacky
|
|
|
2361
2376
|
key: body.key?("key") ? key : nil
|
|
2362
2377
|
)
|
|
2363
2378
|
config = Clacky::SearchConfig.load
|
|
2364
|
-
json_response(res, 200, { ok: true, provider: config["provider"] })
|
|
2379
|
+
json_response(res, 200, { ok: true, provider: config["provider"], keys_masked: masked_search_keys(config) })
|
|
2365
2380
|
rescue => e
|
|
2366
2381
|
json_response(res, 422, { error: e.message })
|
|
2367
2382
|
end
|
|
2368
2383
|
|
|
2384
|
+
private def masked_search_keys(config)
|
|
2385
|
+
config["keys"].transform_values { |key| mask_api_key(key) }
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2369
2388
|
private def search_provider_known?(provider)
|
|
2370
2389
|
return true if provider.empty?
|
|
2371
2390
|
return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
@@ -2382,7 +2401,7 @@ module Clacky
|
|
|
2382
2401
|
return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
|
|
2383
2402
|
|
|
2384
2403
|
key = body["key"].to_s
|
|
2385
|
-
key = Clacky::SearchConfig.
|
|
2404
|
+
key = Clacky::SearchConfig.key_for(provider) if key.empty? || key.include?("****")
|
|
2386
2405
|
return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
2387
2406
|
|
|
2388
2407
|
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
@@ -4590,10 +4609,12 @@ module Clacky
|
|
|
4590
4609
|
|
|
4591
4610
|
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4592
4611
|
ext = File.extname(path).downcase.sub(/\A\./, "")
|
|
4612
|
+
# Extension-less files (Dockerfile, Makefile, Gemfile) declare no
|
|
4613
|
+
# document type, so no app lists them. Plain text is the closest
|
|
4614
|
+
# approximation and keeps the picker useful.
|
|
4615
|
+
ext = "txt" if ext.empty?
|
|
4593
4616
|
|
|
4594
|
-
apps = if
|
|
4595
|
-
[]
|
|
4596
|
-
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4617
|
+
apps = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4597
4618
|
Utils::WindowsAppDetector.apps_for_ext(ext)
|
|
4598
4619
|
else
|
|
4599
4620
|
Utils::MacAppDetector.apps_for_ext(ext)
|
|
@@ -4610,9 +4631,7 @@ module Clacky
|
|
|
4610
4631
|
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
4611
4632
|
|
|
4612
4633
|
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4613
|
-
app = if
|
|
4614
|
-
nil
|
|
4615
|
-
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4634
|
+
app = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4616
4635
|
Utils::WindowsAppDetector.default_app_for(path)
|
|
4617
4636
|
else
|
|
4618
4637
|
Utils::MacAppDetector.default_app_for(path)
|
|
@@ -4682,6 +4701,9 @@ module Clacky
|
|
|
4682
4701
|
when "open"
|
|
4683
4702
|
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
4684
4703
|
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
4704
|
+
# system() returns false when the launcher exits non-zero (broken app
|
|
4705
|
+
# association, no handler). Surface it instead of a silent 200.
|
|
4706
|
+
return json_response(res, 500, { error: "failed to open file" }) unless result
|
|
4685
4707
|
json_response(res, 200, { ok: true })
|
|
4686
4708
|
when "open_with"
|
|
4687
4709
|
result = if Utils::EnvironmentDetector.os_type == :wsl
|
|
@@ -5414,9 +5436,22 @@ module Clacky
|
|
|
5414
5436
|
when "status"
|
|
5415
5437
|
json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
|
|
5416
5438
|
when "diff"
|
|
5417
|
-
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
|
|
5439
|
+
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
|
|
5440
|
+
dir, file: query["file"], context: query["context"],
|
|
5441
|
+
scope: query["scope"], base: query["base"]
|
|
5442
|
+
) })
|
|
5443
|
+
when "review"
|
|
5444
|
+
if query["scope"].to_s == "lastTurn"
|
|
5445
|
+
json_response(res, 200, Clacky::Server::GitPanel.last_turn_review(git_session_agent(session_id)))
|
|
5446
|
+
else
|
|
5447
|
+
json_response(res, 200, Clacky::Server::GitPanel.review(
|
|
5448
|
+
dir, scope: query["scope"], base: query["base"]
|
|
5449
|
+
))
|
|
5450
|
+
end
|
|
5418
5451
|
when "log"
|
|
5419
|
-
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
|
|
5452
|
+
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
|
|
5453
|
+
dir, limit: query["limit"] || 50, unpushed: query["unpushed"].to_s == "1"
|
|
5454
|
+
) })
|
|
5420
5455
|
when "branches"
|
|
5421
5456
|
json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
|
|
5422
5457
|
else
|
|
@@ -5424,24 +5459,6 @@ module Clacky
|
|
|
5424
5459
|
end
|
|
5425
5460
|
end
|
|
5426
5461
|
|
|
5427
|
-
# POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
|
|
5428
|
-
def api_session_git_commit(session_id, req, res)
|
|
5429
|
-
dir = git_session_dir(session_id, res)
|
|
5430
|
-
return unless dir
|
|
5431
|
-
|
|
5432
|
-
unless Clacky::Server::GitPanel.repo?(dir)
|
|
5433
|
-
return json_response(res, 400, { error: "Not a git repository" })
|
|
5434
|
-
end
|
|
5435
|
-
|
|
5436
|
-
body = parse_json_body(req)
|
|
5437
|
-
result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
|
|
5438
|
-
if result[:ok]
|
|
5439
|
-
json_response(res, 200, result)
|
|
5440
|
-
else
|
|
5441
|
-
json_response(res, 422, { error: result[:error] })
|
|
5442
|
-
end
|
|
5443
|
-
end
|
|
5444
|
-
|
|
5445
5462
|
# POST /api/sessions/:id/git/restore - body: { file: }.
|
|
5446
5463
|
# Discards uncommitted changes to one file, back to its HEAD state.
|
|
5447
5464
|
def api_session_git_restore(session_id, req, res)
|
|
@@ -5545,8 +5562,7 @@ module Clacky
|
|
|
5545
5562
|
json_response(res, 404, { error: "Session not found" })
|
|
5546
5563
|
return nil
|
|
5547
5564
|
end
|
|
5548
|
-
|
|
5549
|
-
agent = session && session[:agent]
|
|
5565
|
+
agent = git_session_agent(session_id)
|
|
5550
5566
|
unless agent
|
|
5551
5567
|
json_response(res, 404, { error: "Session not found" })
|
|
5552
5568
|
return nil
|
|
@@ -5558,6 +5574,11 @@ module Clacky
|
|
|
5558
5574
|
end
|
|
5559
5575
|
dir
|
|
5560
5576
|
end
|
|
5577
|
+
|
|
5578
|
+
private def git_session_agent(session_id)
|
|
5579
|
+
session = @registry.get(session_id)
|
|
5580
|
+
session && session[:agent]
|
|
5581
|
+
end
|
|
5561
5582
|
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
5562
5583
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
5563
5584
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
@@ -5571,7 +5592,11 @@ module Clacky
|
|
|
5571
5592
|
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
5572
5593
|
|
|
5573
5594
|
root = File.expand_path(agent.working_dir.to_s)
|
|
5574
|
-
|
|
5595
|
+
unless Dir.exist?(root)
|
|
5596
|
+
# `code` lets the client tell "the folder was deleted" apart from any
|
|
5597
|
+
# other failure and explain it, instead of showing a generic error.
|
|
5598
|
+
return json_response(res, 404, { error: "Working directory not found", code: "working_dir_missing", path: root })
|
|
5599
|
+
end
|
|
5575
5600
|
|
|
5576
5601
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
5577
5602
|
rel = query["path"].to_s
|
|
@@ -5596,7 +5621,9 @@ module Clacky
|
|
|
5596
5621
|
end
|
|
5597
5622
|
end
|
|
5598
5623
|
|
|
5599
|
-
|
|
5624
|
+
unless Dir.exist?(target)
|
|
5625
|
+
return json_response(res, 404, { error: "Directory not found", code: "directory_missing" })
|
|
5626
|
+
end
|
|
5600
5627
|
entries = Dir.children(target).reject do |name|
|
|
5601
5628
|
IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
|
|
5602
5629
|
end
|
|
@@ -7280,6 +7307,71 @@ module Clacky
|
|
|
7280
7307
|
json_response(res, 500, { error: e.message })
|
|
7281
7308
|
end
|
|
7282
7309
|
|
|
7310
|
+
# GET /api/projects/:id/git?dir= — branch state of a work tree, used by
|
|
7311
|
+
# the new-session branch picker. `dir` overrides the project's own
|
|
7312
|
+
# working dir, since a session may run in a different repo. `repo: false`
|
|
7313
|
+
# covers a missing working dir, a missing directory, and plain non-git
|
|
7314
|
+
# folders, so the picker can simply hide itself.
|
|
7315
|
+
def api_project_git(project_id, req, res)
|
|
7316
|
+
project = @project_manager.find(project_id)
|
|
7317
|
+
return json_response(res, 404, { error: "Project not found" }) unless project
|
|
7318
|
+
|
|
7319
|
+
dir = git_work_tree(project, git_dir_param(req))
|
|
7320
|
+
return json_response(res, 200, { repo: false }) unless dir
|
|
7321
|
+
|
|
7322
|
+
json_response(res, 200, {
|
|
7323
|
+
repo: true,
|
|
7324
|
+
branch: Clacky::Server::GitPanel.branch_name(dir),
|
|
7325
|
+
branches: Clacky::Server::GitPanel.branches(dir),
|
|
7326
|
+
})
|
|
7327
|
+
end
|
|
7328
|
+
|
|
7329
|
+
# POST /api/projects/:id/git/checkout — body: { branch:, create:?, from:?, dir:? }
|
|
7330
|
+
# Switches a work tree. `create: true` branches off `from`, which may be
|
|
7331
|
+
# a remote ref (origin/feature) to get a tracking branch.
|
|
7332
|
+
def api_project_git_checkout(project_id, req, res)
|
|
7333
|
+
project = @project_manager.find(project_id)
|
|
7334
|
+
return json_response(res, 404, { error: "Project not found" }) unless project
|
|
7335
|
+
|
|
7336
|
+
body = parse_json_body(req)
|
|
7337
|
+
dir = git_work_tree(project, body["dir"])
|
|
7338
|
+
return json_response(res, 400, { error: "Not a git repository" }) unless dir
|
|
7339
|
+
|
|
7340
|
+
result = Clacky::Server::GitPanel.checkout(
|
|
7341
|
+
dir,
|
|
7342
|
+
branch: body["branch"],
|
|
7343
|
+
create: body["create"] == true,
|
|
7344
|
+
from: body["from"]
|
|
7345
|
+
)
|
|
7346
|
+
|
|
7347
|
+
unless result[:ok]
|
|
7348
|
+
return json_response(res, 422, { error: result[:error] })
|
|
7349
|
+
end
|
|
7350
|
+
|
|
7351
|
+
json_response(res, 200, {
|
|
7352
|
+
ok: true,
|
|
7353
|
+
branch: Clacky::Server::GitPanel.branch_name(dir),
|
|
7354
|
+
branches: Clacky::Server::GitPanel.branches(dir),
|
|
7355
|
+
})
|
|
7356
|
+
end
|
|
7357
|
+
|
|
7358
|
+
def git_dir_param(req)
|
|
7359
|
+
URI.decode_www_form(req.query_string.to_s).to_h["dir"]
|
|
7360
|
+
rescue ArgumentError
|
|
7361
|
+
nil
|
|
7362
|
+
end
|
|
7363
|
+
|
|
7364
|
+
# Resolves the work tree a git request targets: the explicit `dir` when
|
|
7365
|
+
# given, otherwise the project's own working dir. Nil when the path is
|
|
7366
|
+
# not an existing git repository.
|
|
7367
|
+
def git_work_tree(project, dir_param)
|
|
7368
|
+
raw = dir_param.to_s.strip
|
|
7369
|
+
dir = raw.empty? ? project[:working_dir].to_s : File.expand_path(raw)
|
|
7370
|
+
return nil if dir.empty? || !Dir.exist?(dir)
|
|
7371
|
+
return nil unless Clacky::Server::GitPanel.repo?(dir)
|
|
7372
|
+
dir
|
|
7373
|
+
end
|
|
7374
|
+
|
|
7283
7375
|
# PATCH /api/projects/:id - update a project
|
|
7284
7376
|
# Body: { name:?, description:?, color:?, working_dir:?, pinned:? } - only provided fields are changed
|
|
7285
7377
|
def api_update_project(project_id, req, res)
|
|
@@ -8403,6 +8495,7 @@ module Clacky
|
|
|
8403
8495
|
Thread.current[:task_epoch] = epoch
|
|
8404
8496
|
run_result = task.call
|
|
8405
8497
|
awaiting = false
|
|
8498
|
+
web_ui = nil
|
|
8406
8499
|
owns_epoch = true
|
|
8407
8500
|
loop do
|
|
8408
8501
|
owns_epoch = @registry.current_epoch(session_id).to_i == epoch.to_i
|
|
@@ -8412,6 +8505,7 @@ module Clacky
|
|
|
8412
8505
|
@registry.with_session(session_id) do |s|
|
|
8413
8506
|
owns_epoch = s[:epoch].to_i == epoch.to_i
|
|
8414
8507
|
next unless owns_epoch
|
|
8508
|
+
web_ui = s[:ui]
|
|
8415
8509
|
awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
|
|
8416
8510
|
pending = agent.take_pending_input if Agent.task_completed?(run_result)
|
|
8417
8511
|
unless pending
|
|
@@ -8430,7 +8524,11 @@ module Clacky
|
|
|
8430
8524
|
# session history: a chime is a live cue, never replayed on refresh.
|
|
8431
8525
|
# Also fires when awaiting feedback — that is precisely when the user
|
|
8432
8526
|
# most needs pulling back; the flag lets the client word it differently.
|
|
8433
|
-
|
|
8527
|
+
finished = { type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting }
|
|
8528
|
+
# Lets clients not viewing this session keep the summary until it is first opened.
|
|
8529
|
+
complete = web_ui&.last_complete
|
|
8530
|
+
finished[:complete] = complete if complete&.dig(:task_id) && run_result.is_a?(Hash) && complete[:task_id] == run_result[:task_id]
|
|
8531
|
+
broadcast_all(finished)
|
|
8434
8532
|
@session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
|
|
8435
8533
|
# Start idle compression timer now that the agent is idle
|
|
8436
8534
|
idle_timer&.start
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "timeout"
|
|
6
|
+
require "uri"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
8
9
|
module Server
|
|
@@ -47,18 +48,53 @@ module Clacky
|
|
|
47
48
|
".ogg" => "audio/ogg"
|
|
48
49
|
}.freeze
|
|
49
50
|
|
|
50
|
-
# Injected into preview HTML responses. The preview iframe is
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
51
|
+
# Injected into preview HTML responses. The preview iframe is cross-origin
|
|
52
|
+
# to the host page, so the host cannot read its location (and the iframe's
|
|
53
|
+
# src property does not follow internal navigations). Each served page
|
|
54
|
+
# therefore announces its own URL to the parent. Re-reporting is not just
|
|
55
|
+
# the initial load: Turbo/htmx/React-Router navigations swap the DOM
|
|
56
|
+
# without a full page load, so the same script also listens for their
|
|
57
|
+
# events, patches the History API and falls back to a slow poll.
|
|
58
|
+
PREVIEW_NAV_REPORT = <<~JS.freeze
|
|
59
|
+
<script>(function(){
|
|
60
|
+
var last = null;
|
|
61
|
+
function report(){
|
|
62
|
+
try {
|
|
63
|
+
if (location.href === last) return;
|
|
64
|
+
last = location.href;
|
|
65
|
+
parent.postMessage({__clackyPreview:1,href:location.href},"*");
|
|
66
|
+
} catch(e){}
|
|
67
|
+
}
|
|
68
|
+
report();
|
|
69
|
+
["turbo:load","turbo:render","turbo:frame-load","htmx:afterSwap","pjax:end","DOMContentLoaded"].forEach(function(n){
|
|
70
|
+
document.addEventListener(n, report);
|
|
71
|
+
});
|
|
72
|
+
window.addEventListener("popstate", report);
|
|
73
|
+
window.addEventListener("hashchange", report);
|
|
74
|
+
var push = history.pushState, replace = history.replaceState;
|
|
75
|
+
history.pushState = function(){ push.apply(history, arguments); report(); };
|
|
76
|
+
history.replaceState = function(){ replace.apply(history, arguments); report(); };
|
|
77
|
+
setInterval(report, 500);
|
|
78
|
+
})()</script>
|
|
79
|
+
JS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Must match ISOLATED_PREVIEW_HOST in
|
|
83
|
+
# default_extensions/preview/panels/preview/view.js. Chrome resolves
|
|
84
|
+
# every *.localhost name to loopback, so the preview frame runs on this
|
|
85
|
+
# origin while the UI stays on 127.0.0.1/localhost.
|
|
86
|
+
ISOLATED_PREVIEW_HOST = "preview.localhost"
|
|
87
|
+
|
|
88
|
+
# Hostnames a dev server may redirect to with an absolute URL. Only these
|
|
89
|
+
# are folded back into the proxy prefix; anything else is a deliberate
|
|
90
|
+
# off-box redirect and is passed through untouched.
|
|
91
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 localhost ::1].freeze
|
|
55
92
|
|
|
56
93
|
# Headers dropped when proxying a localhost dev server through the
|
|
57
|
-
# preview panel. X-Frame-Options is what blocks the sandboxed iframe
|
|
58
|
-
# hop-by-hop headers
|
|
59
|
-
# (
|
|
60
|
-
#
|
|
61
|
-
# rest of the policy survives.
|
|
94
|
+
# preview panel. X-Frame-Options is what blocks the sandboxed iframe and
|
|
95
|
+
# hop-by-hop headers are recomputed by WEBrick. Set-Cookie is handled
|
|
96
|
+
# separately (see #preview_cookie) because the isolated preview origin
|
|
97
|
+
# needs the upstream session to survive inside a third-party frame.
|
|
62
98
|
PROXY_SKIP_HEADERS = %w[
|
|
63
99
|
x-frame-options
|
|
64
100
|
transfer-encoding
|
|
@@ -70,7 +106,6 @@ module Clacky
|
|
|
70
106
|
te
|
|
71
107
|
trailer
|
|
72
108
|
upgrade
|
|
73
|
-
set-cookie
|
|
74
109
|
].freeze
|
|
75
110
|
|
|
76
111
|
# Connection-stage failures when dialing the localhost dev server. The
|
|
@@ -189,11 +224,8 @@ module Clacky
|
|
|
189
224
|
# the document start) so the reporter runs before page scripts while a
|
|
190
225
|
# leading <!DOCTYPE> stays first — prepending would flip the document
|
|
191
226
|
# into quirks mode.
|
|
192
|
-
private def
|
|
193
|
-
|
|
194
|
-
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
195
|
-
|
|
196
|
-
html = body.force_encoding(Encoding::UTF_8)
|
|
227
|
+
private def inject_nav_report(html)
|
|
228
|
+
html = html.force_encoding(Encoding::UTF_8)
|
|
197
229
|
if (m = html.match(/<head[^>]*>/i))
|
|
198
230
|
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
199
231
|
elsif (m = html.match(/<html[^>]*>/i))
|
|
@@ -206,6 +238,13 @@ module Clacky
|
|
|
206
238
|
html
|
|
207
239
|
end
|
|
208
240
|
|
|
241
|
+
private def preview_body(abs)
|
|
242
|
+
body = File.binread(abs)
|
|
243
|
+
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
244
|
+
|
|
245
|
+
inject_nav_report(body)
|
|
246
|
+
end
|
|
247
|
+
|
|
209
248
|
# Decode %XX escapes in a preview path segment without treating "+" as
|
|
210
249
|
# space: "+" is a legal literal character in file names.
|
|
211
250
|
private def preview_decode_path(seg)
|
|
@@ -285,6 +324,15 @@ module Clacky
|
|
|
285
324
|
next if value.nil? || value.empty?
|
|
286
325
|
end
|
|
287
326
|
next if PROXY_SKIP_HEADERS.include?(down)
|
|
327
|
+
if down == "set-cookie"
|
|
328
|
+
cookie = preview_cookie(value)
|
|
329
|
+
res.cookies << cookie if cookie && isolated_preview_request?(req)
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
if down == "location"
|
|
333
|
+
res[name] = rewrite_preview_location(value, port, req)
|
|
334
|
+
next
|
|
335
|
+
end
|
|
288
336
|
if down == "content-type"
|
|
289
337
|
is_html = value.start_with?("text/html")
|
|
290
338
|
is_css = value.start_with?("text/css")
|
|
@@ -300,7 +348,7 @@ module Clacky
|
|
|
300
348
|
if req.request_method != "HEAD"
|
|
301
349
|
body = resp.body.to_s
|
|
302
350
|
if is_html
|
|
303
|
-
body = rewrite_preview_paths(body, port)
|
|
351
|
+
body = inject_nav_report(rewrite_preview_paths(body, port))
|
|
304
352
|
elsif is_css
|
|
305
353
|
body = rewrite_preview_css(body, port)
|
|
306
354
|
end
|
|
@@ -319,6 +367,59 @@ module Clacky
|
|
|
319
367
|
stripped.empty? ? nil : stripped
|
|
320
368
|
end
|
|
321
369
|
|
|
370
|
+
# Rewrites an upstream Set-Cookie so it survives inside the preview
|
|
371
|
+
# frame. The frame is a third-party context for the host UI, where Chrome
|
|
372
|
+
# drops SameSite=Lax/Strict cookies outright, so the session cookie the
|
|
373
|
+
# dev server just set would be discarded and every page would look like a
|
|
374
|
+
# fresh, cookie-less visit. SameSite=None needs Secure, which Chrome
|
|
375
|
+
# accepts over http for *.localhost because it is a trustworthy origin.
|
|
376
|
+
# Domain is dropped so the cookie stays pinned to the preview origin and
|
|
377
|
+
# can never be read by the host UI.
|
|
378
|
+
private def preview_cookie(value)
|
|
379
|
+
parts = value.to_s.split(";").map(&:strip).reject(&:empty?)
|
|
380
|
+
return nil if parts.empty?
|
|
381
|
+
|
|
382
|
+
kept = parts[1..-1].reject do |attr|
|
|
383
|
+
attr =~ /\A(?:domain|samesite)\s*=/i || attr =~ /\Asecure\z/i
|
|
384
|
+
end
|
|
385
|
+
([parts.first] + kept + ["SameSite=None", "Secure"]).join("; ")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private def isolated_preview_request?(req)
|
|
389
|
+
req["Host"].to_s.split(":").first.to_s.downcase == ISOLATED_PREVIEW_HOST
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# An upstream 3xx points at the dev server root (/dashboard). Inside the
|
|
393
|
+
# isolated preview frame that resolves against the proxy origin and loses
|
|
394
|
+
# the /preview/p/<port> prefix, so the redirect lands on a 404 — and the
|
|
395
|
+
# address bar never sees the new page. Keep root-relative redirects inside
|
|
396
|
+
# the prefix and fold absolute ones that point back at the same dev
|
|
397
|
+
# server. Off-box and already-prefixed URLs pass through untouched.
|
|
398
|
+
private def rewrite_preview_location(value, port, req)
|
|
399
|
+
return value unless isolated_preview_request?(req)
|
|
400
|
+
|
|
401
|
+
prefix = "/preview/p/#{port}"
|
|
402
|
+
if value.start_with?("/") && !value.start_with?("//")
|
|
403
|
+
return value if value == prefix || value.start_with?("#{prefix}/", "#{prefix}?")
|
|
404
|
+
|
|
405
|
+
return "#{prefix}#{value}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
uri = begin
|
|
409
|
+
URI.parse(value)
|
|
410
|
+
rescue URI::InvalidURIError
|
|
411
|
+
return value
|
|
412
|
+
end
|
|
413
|
+
return value unless uri.host
|
|
414
|
+
return value unless LOOPBACK_HOSTS.include?(uri.host.downcase)
|
|
415
|
+
return value unless uri.port == port
|
|
416
|
+
|
|
417
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
418
|
+
path += "?#{uri.query}" if uri.query
|
|
419
|
+
path += "##{uri.fragment}" if uri.fragment
|
|
420
|
+
"#{prefix}#{path}"
|
|
421
|
+
end
|
|
422
|
+
|
|
322
423
|
# Rewrites absolute-path resource URLs (/assets/app.js) in a proxied HTML
|
|
323
424
|
# page to the proxy prefix so the browser resolves them against the dev
|
|
324
425
|
# server instead of the host origin. Protocol-relative (//), already
|
|
@@ -141,13 +141,15 @@ module Clacky
|
|
|
141
141
|
list_tasks.map do |task_name|
|
|
142
142
|
content = begin; read_task(task_name); rescue StandardError; ""; end
|
|
143
143
|
schedule = schedule_map[task_name] || {}
|
|
144
|
+
cron = schedule["cron"]
|
|
144
145
|
{
|
|
145
146
|
"name" => task_name,
|
|
146
147
|
"content" => content,
|
|
147
|
-
"cron" =>
|
|
148
|
+
"cron" => cron,
|
|
148
149
|
"enabled" => schedule.fetch("enabled", true),
|
|
149
150
|
"scheduled" => !schedule.empty?,
|
|
150
|
-
"last_run" => last_runs[task_name]
|
|
151
|
+
"last_run" => last_runs[task_name],
|
|
152
|
+
"next_run" => cron ? cron_next_run(cron) : nil
|
|
151
153
|
}
|
|
152
154
|
end
|
|
153
155
|
end
|
|
@@ -383,6 +385,83 @@ module Clacky
|
|
|
383
385
|
|
|
384
386
|
# ── Cron parsing ─────────────────────────────────────────────────────────
|
|
385
387
|
|
|
388
|
+
# First time the 5-field cron expression fires strictly after +from+.
|
|
389
|
+
# Walks day by day (two years max) and only expands the hour/minute grid
|
|
390
|
+
# for days that match, so a yearly task costs ~365 cheap comparisons.
|
|
391
|
+
# Returns nil for malformed expressions and impossible ones (Feb 30).
|
|
392
|
+
private def cron_next_run(expr, from: Time.now)
|
|
393
|
+
fields = expr.to_s.strip.split(/\s+/)
|
|
394
|
+
return nil unless fields.size == 5
|
|
395
|
+
|
|
396
|
+
sets = [
|
|
397
|
+
cron_field_values(fields[0], 0, 59),
|
|
398
|
+
cron_field_values(fields[1], 0, 23),
|
|
399
|
+
cron_field_values(fields[2], 1, 31),
|
|
400
|
+
cron_field_values(fields[3], 1, 12),
|
|
401
|
+
cron_field_values(fields[4], 0, 6)
|
|
402
|
+
]
|
|
403
|
+
return nil if sets.any?(&:nil?)
|
|
404
|
+
|
|
405
|
+
minutes, hours, doms, months, dows = sets
|
|
406
|
+
return nil if minutes.empty? || hours.empty? || doms.empty? || months.empty? || dows.empty?
|
|
407
|
+
|
|
408
|
+
# Anchor at noon so a day-step never lands on the wrong date across a
|
|
409
|
+
# DST boundary (Time#+ is absolute seconds, not calendar days).
|
|
410
|
+
noon = Time.local(from.year, from.month, from.day, 12)
|
|
411
|
+
# Minute-of-day of the first candidate; same-day scans start here.
|
|
412
|
+
floor = from.hour * 60 + from.min + 1
|
|
413
|
+
|
|
414
|
+
(0..731).each do |offset|
|
|
415
|
+
day = noon + offset * 86_400
|
|
416
|
+
next unless months.include?(day.month) && doms.include?(day.day) && dows.include?(day.wday)
|
|
417
|
+
|
|
418
|
+
limit = offset.zero? ? floor : 0
|
|
419
|
+
hours.each do |h|
|
|
420
|
+
next if h * 60 + minutes.last < limit
|
|
421
|
+
|
|
422
|
+
minutes.each do |m|
|
|
423
|
+
next if h * 60 + m < limit
|
|
424
|
+
|
|
425
|
+
return Time.local(day.year, day.month, day.day, h, m, 0)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Concrete values a single cron field can take, mirroring the semantics of
|
|
434
|
+
# #cron_part_matches?. Returns nil when the field is unparseable.
|
|
435
|
+
private def cron_field_values(field, min, max)
|
|
436
|
+
values = []
|
|
437
|
+
field.to_s.split(",").each do |part|
|
|
438
|
+
part = part.strip
|
|
439
|
+
base, step = part.split("/")
|
|
440
|
+
step = step ? step.to_i : 1
|
|
441
|
+
return nil if step <= 0
|
|
442
|
+
|
|
443
|
+
lo, hi = if base == "*"
|
|
444
|
+
[min, max]
|
|
445
|
+
elsif base.include?("-")
|
|
446
|
+
base.split("-").map(&:to_i)
|
|
447
|
+
else
|
|
448
|
+
value = Integer(base, exception: false)
|
|
449
|
+
return nil if value.nil?
|
|
450
|
+
[value, value]
|
|
451
|
+
end
|
|
452
|
+
return nil if lo.nil? || hi.nil? || hi < lo
|
|
453
|
+
|
|
454
|
+
(lo..hi).each do |v|
|
|
455
|
+
next unless v.between?(min, max)
|
|
456
|
+
# `*` steps from zero (matches value % step), ranges step from lo.
|
|
457
|
+
next unless base == "*" ? (v % step).zero? : ((v - lo) % step).zero?
|
|
458
|
+
|
|
459
|
+
values << v
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
values.uniq.sort
|
|
463
|
+
end
|
|
464
|
+
|
|
386
465
|
# Returns true if the 5-field cron expression matches the given Time.
|
|
387
466
|
# Fields: minute hour day-of-month month day-of-week
|
|
388
467
|
private def cron_matches?(expr, time)
|