openclacky 0.9.36 → 0.9.38
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 +13 -0
- data/lib/clacky/agent/session_serializer.rb +6 -1
- data/lib/clacky/agent/tool_executor.rb +1 -0
- data/lib/clacky/message_history.rb +43 -2
- data/lib/clacky/providers.rb +21 -0
- data/lib/clacky/server/http_server.rb +68 -25
- data/lib/clacky/server/session_registry.rb +32 -5
- data/lib/clacky/tools/edit.rb +11 -1
- data/lib/clacky/tools/file_reader.rb +19 -3
- data/lib/clacky/tools/glob.rb +1 -0
- data/lib/clacky/tools/grep.rb +12 -3
- data/lib/clacky/tools/terminal.rb +65 -0
- data/lib/clacky/utils/model_pricing.rb +45 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/auth.js +118 -69
- data/lib/clacky/web/sessions.js +7 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43106e90c922f80d2bb342d051d68a6cb4efa045143c97b08c26bf776a952a6d
|
|
4
|
+
data.tar.gz: 1faee045587d15517d25ab68df5003c5a210db1d009b9b8363d6c27f0b53157d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e696b00d7e79c968851c1f5a194fe352d8d9f7971ca37f56321705dd83ec8a71a3cae1836468df94b3877d5ac6a25cf31c991f30e10d0997ec0a6b06b2c9825
|
|
7
|
+
data.tar.gz: 92628aced329eb6e7c9567034873a276a15a64692e3796cf995cfbe1303132d6720fc69115332ab69371d07e5f4963377c80a5bad568797619c58ecc48fca04c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.37] - 2026-04-24
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Critical: pinned sessions could silently disappear from the sidebar** ("the pinned one isn't showing, and refreshing sometimes fixes it"). Root cause: the backend `list` endpoint only sorted by `created_at` and applied `limit` blindly, so a pinned session older than the first page's rows was cut off entirely — the frontend's `byPinnedAndTime` sort never saw it. "Refreshing sometimes worked" only if the pinned session happened to be recent enough to land in the first 20 rows. Fix: `SessionRegistry#list` now partitions results and **always returns ALL matching pinned sessions on the first page regardless of `limit`**, followed by up to `limit` non-pinned sessions. The `before` cursor applies only to the non-pinned section, so "load more" pages never re-send or duplicate pinned rows. `/api/sessions`'s `has_more` is now computed from non-pinned overflow only. Frontend `loadMore` cursor also excludes pinned rows so pagination jumps correctly. Regression specs cover: (a) an old pinned session still appears when `limit=3`, (b) multiple pinned sessions all fit on page one with `limit=1`, (c) pinned sessions never duplicate into `before`-cursor pages.
|
|
14
|
+
- **Critical: saving one model in Web UI Settings silently wiped other models' API keys.** The 0.9.36 index→id refactor (commit `b61e22e`) rebuilt each model hash from scratch on save (`"api_key" => api_key.to_s`), dropping the old `existing["api_key"] = api_key if api_key` guard. Combined with `/api/config` returning only `api_key_masked` (never `api_key`), every non-edited row in the POST body arrived with `api_key: undefined` — the backend then rewrote those rows' keys to `""`. Now `api_save_config` has three explicit cases for resolving `api_key`: (1) masked placeholder → keep stored key, (2) **missing/blank on an existing row → keep stored key (this fix)**, (3) otherwise use incoming value. Brand-new models (no `id`) still create with an empty key as before.
|
|
15
|
+
- **Critical: in-app upgrade no longer falsely reports failure.** The 0.9.36 upgrade flow shared a PTY helper (`run_shell`) with the new unified Terminal tool, which — by design — returns early with a `session_id` when command output stays quiet for 3 seconds. Long-running `gem install` operations routinely hit this during dependency resolution, causing the Web UI to show `✗ Upgrade failed.` even when the gem installed successfully. `run_shell` now delegates to a new `Terminal.run_sync` Ruby API that polls until the command truly completes, and `finish_upgrade` additionally re-checks the installed gem version as a defensive fallback.
|
|
16
|
+
- **Critical: "历史记录获取失败 (500: source sequence is illegal/malformed utf-8)" when opening a session.** When `file_reader` / `edit` / `grep` / `glob` encountered a file with non-UTF-8 bytes (e.g. GBK-encoded text or a Chinese Windows-exported CSV), the dirty bytes flowed through tool results into the agent history and session chunks on disk. Later, when `GET /api/sessions/:id/messages` replayed that history, `JSON.generate` would blow up on the invalid byte sequence and return 500. Now every IO source point scrubs invalid bytes to U+FFFD (`�`) at read time: `file_reader` (both content and directory entry names), `edit`, `grep` (`File.foreach` + context `readlines`), `glob` (`Dir.glob` path strings), `session_serializer` (chunk md replay), and `tool_executor` (diff preview). A defense-in-depth layer in `MessageHistory#append` / `#replace_all` recursively sanitizes every string that enters the message tree — so even a future tool that forgets to scrub cannot poison the session.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **New `Terminal.run_sync` internal API** for Ruby callers that need synchronous command capture (drop-in replacement for `Open3.capture2e`, but using the same PTY + login-shell + Security pipeline as the AI-facing tool).
|
|
20
|
+
- **DeepSeek V4 provider preset.** New `deepseekv4` entry in `Clacky::Providers` (positioned right after `openrouter`) with default model `deepseek-v4-pro` and models list `deepseek-v4-flash`, `deepseek-v4-pro`, plus the deprecated-aliases `deepseek-chat` / `deepseek-reasoner` (to be removed on 2026-07-24). Uses the OpenAI-compatible endpoint `https://api.deepseek.com`; for Anthropic-format usage, point `base_url` at `https://api.deepseek.com/anthropic` and switch `api` to `anthropic-messages`.
|
|
21
|
+
- **DeepSeek V4 pricing.** Added `deepseek-v4-flash` ($0.14 in / $0.28 out / $0.028 cache-hit per MTok) and `deepseek-v4-pro` ($1.74 in / $3.48 out / $0.145 cache-hit per MTok) to `Clacky::ModelPricing::PRICING_TABLE`. Legacy aliases `deepseek-chat` and `deepseek-reasoner` normalize to `deepseek-v4-flash`. DeepSeek has no separate cache-write charge, so cache writes are billed at the cache-miss (input) rate. Prices sourced from the official pricing page (USD per 1M tokens).
|
|
22
|
+
|
|
10
23
|
## [0.9.36] - 2026-04-24
|
|
11
24
|
|
|
12
25
|
### Fixed
|
|
@@ -243,7 +243,12 @@ module Clacky
|
|
|
243
243
|
end
|
|
244
244
|
visited = visited.dup.add(canonical)
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
# Scrub invalid UTF-8 bytes defensively — chunk files written before
|
|
247
|
+
# the 0.9.37 fix may contain poisoned bytes from file_reader results.
|
|
248
|
+
raw = File.read(resolved).then do |s|
|
|
249
|
+
s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
|
|
250
|
+
s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
251
|
+
end
|
|
247
252
|
|
|
248
253
|
# Parse YAML front matter to get archived_at for synthetic timestamps
|
|
249
254
|
archived_at = nil
|
|
@@ -358,6 +358,7 @@ module Clacky
|
|
|
358
358
|
@ui&.show_diff("", new_content, max_lines: 50)
|
|
359
359
|
else
|
|
360
360
|
old_content = File.read(expanded_path)
|
|
361
|
+
old_content = old_content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") unless old_content.encoding == Encoding::UTF_8 && old_content.valid_encoding?
|
|
361
362
|
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
362
363
|
end
|
|
363
364
|
nil
|
|
@@ -34,7 +34,7 @@ module Clacky
|
|
|
34
34
|
if message[:role] == "user"
|
|
35
35
|
drop_dangling_tool_calls!
|
|
36
36
|
end
|
|
37
|
-
@messages << message
|
|
37
|
+
@messages << deep_sanitize_utf8(message)
|
|
38
38
|
self
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -53,7 +53,7 @@ module Clacky
|
|
|
53
53
|
|
|
54
54
|
# Replace the entire message list (used by compression rebuild).
|
|
55
55
|
def replace_all(new_messages)
|
|
56
|
-
@messages = new_messages.
|
|
56
|
+
@messages = new_messages.map { |m| deep_sanitize_utf8(m) }
|
|
57
57
|
self
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -229,5 +229,46 @@ module Clacky
|
|
|
229
229
|
private def strip_internal_fields(message)
|
|
230
230
|
message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
|
|
231
231
|
end
|
|
232
|
+
|
|
233
|
+
# Defense-in-depth: recursively scrub invalid UTF-8 bytes from every String
|
|
234
|
+
# stored in the message tree. Even if a tool forgets to scrub its output,
|
|
235
|
+
# nothing poisoned will ever reach session persistence or JSON.generate.
|
|
236
|
+
#
|
|
237
|
+
# Fast path: if the tree contains only valid UTF-8 strings, the original
|
|
238
|
+
# object is returned unchanged — preserving object identity for callers
|
|
239
|
+
# that rely on `equal?` (e.g. rollback_before).
|
|
240
|
+
# Slow path: any invalid byte triggers a rebuild with scrubbed strings
|
|
241
|
+
# (invalid bytes → U+FFFD).
|
|
242
|
+
private def deep_sanitize_utf8(obj)
|
|
243
|
+
case obj
|
|
244
|
+
when String
|
|
245
|
+
return obj if obj.encoding == Encoding::UTF_8 && obj.valid_encoding?
|
|
246
|
+
obj.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
247
|
+
when Hash
|
|
248
|
+
return obj unless contains_dirty_utf8?(obj)
|
|
249
|
+
obj.transform_values { |v| deep_sanitize_utf8(v) }
|
|
250
|
+
when Array
|
|
251
|
+
return obj unless contains_dirty_utf8?(obj)
|
|
252
|
+
obj.map { |v| deep_sanitize_utf8(v) }
|
|
253
|
+
else
|
|
254
|
+
obj
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Cheap recursive check: does this subtree contain any invalid-UTF-8 string?
|
|
259
|
+
# Short-circuits on first offender. Keeps the common case (all valid UTF-8)
|
|
260
|
+
# allocation-free.
|
|
261
|
+
private def contains_dirty_utf8?(obj)
|
|
262
|
+
case obj
|
|
263
|
+
when String
|
|
264
|
+
!(obj.encoding == Encoding::UTF_8 && obj.valid_encoding?)
|
|
265
|
+
when Hash
|
|
266
|
+
obj.any? { |_, v| contains_dirty_utf8?(v) }
|
|
267
|
+
when Array
|
|
268
|
+
obj.any? { |v| contains_dirty_utf8?(v) }
|
|
269
|
+
else
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
end
|
|
232
273
|
end
|
|
233
274
|
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -41,6 +41,27 @@ module Clacky
|
|
|
41
41
|
"website_url" => "https://openrouter.ai/keys"
|
|
42
42
|
}.freeze,
|
|
43
43
|
|
|
44
|
+
"deepseekv4" => {
|
|
45
|
+
"name" => "DeepSeek V4",
|
|
46
|
+
# DeepSeek API is compatible with both OpenAI and Anthropic formats.
|
|
47
|
+
# We use the OpenAI-compatible endpoint here (matches kimi/minimax/glm style).
|
|
48
|
+
# For Anthropic-format usage, point base_url at https://api.deepseek.com/anthropic
|
|
49
|
+
# and change "api" to "anthropic-messages".
|
|
50
|
+
"base_url" => "https://api.deepseek.com",
|
|
51
|
+
"api" => "openai-completions",
|
|
52
|
+
"default_model" => "deepseek-v4-pro",
|
|
53
|
+
# Note: deepseek-chat and deepseek-reasoner are legacy aliases being
|
|
54
|
+
# deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
|
|
55
|
+
# and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
|
|
56
|
+
"models" => [
|
|
57
|
+
"deepseek-v4-flash",
|
|
58
|
+
"deepseek-v4-pro",
|
|
59
|
+
"deepseek-chat",
|
|
60
|
+
"deepseek-reasoner"
|
|
61
|
+
],
|
|
62
|
+
"website_url" => "https://platform.deepseek.com/api_keys"
|
|
63
|
+
}.freeze,
|
|
64
|
+
|
|
44
65
|
"minimax" => {
|
|
45
66
|
"name" => "Minimax",
|
|
46
67
|
"base_url" => "https://api.minimaxi.com/v1",
|
|
@@ -468,10 +468,19 @@ module Clacky
|
|
|
468
468
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
469
469
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
470
470
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
471
|
-
|
|
471
|
+
|
|
472
|
+
# Fetch one extra NON-PINNED row to detect has_more without a separate count query.
|
|
473
|
+
# `registry.list` always returns ALL matching pinned rows first (on the
|
|
474
|
+
# first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
|
|
475
|
+
# So has_more is determined by whether the non-pinned section overflowed.
|
|
472
476
|
sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
|
|
473
|
-
|
|
474
|
-
|
|
477
|
+
|
|
478
|
+
# Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
|
|
479
|
+
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
480
|
+
has_more = non_pinned_part.size > limit
|
|
481
|
+
non_pinned_part = non_pinned_part.first(limit)
|
|
482
|
+
sessions = pinned_part + non_pinned_part
|
|
483
|
+
|
|
475
484
|
json_response(res, 200, { sessions: sessions, has_more: has_more })
|
|
476
485
|
end
|
|
477
486
|
|
|
@@ -930,8 +939,12 @@ module Clacky
|
|
|
930
939
|
key.empty? ? nil : key
|
|
931
940
|
end
|
|
932
941
|
|
|
933
|
-
# Extract bearer token
|
|
934
|
-
# Priority: Authorization: Bearer > ?access_key=
|
|
942
|
+
# Extract bearer token or query param from a WEBrick request.
|
|
943
|
+
# Priority: Authorization: Bearer > ?access_key=
|
|
944
|
+
# The query string form is only used by WebSocket connections, which
|
|
945
|
+
# cannot set custom headers from the browser. All HTTP clients —
|
|
946
|
+
# including the web UI (via a fetch interceptor in auth.js) — use the
|
|
947
|
+
# Authorization header.
|
|
935
948
|
private def extract_key(req)
|
|
936
949
|
auth = req["Authorization"].to_s.strip
|
|
937
950
|
if auth.start_with?("Bearer ")
|
|
@@ -943,10 +956,6 @@ module Clacky
|
|
|
943
956
|
token = query["access_key"].to_s.strip
|
|
944
957
|
return token unless token.empty?
|
|
945
958
|
|
|
946
|
-
req.cookies.each do |c|
|
|
947
|
-
return c.value if c.name == "clacky_access_key" && !c.value.to_s.empty?
|
|
948
|
-
end
|
|
949
|
-
|
|
950
959
|
nil
|
|
951
960
|
end
|
|
952
961
|
|
|
@@ -1119,7 +1128,18 @@ module Clacky
|
|
|
1119
1128
|
end
|
|
1120
1129
|
|
|
1121
1130
|
# Broadcast final upgrade result with appropriate log message.
|
|
1131
|
+
#
|
|
1132
|
+
# Defensive post-check: if `run_shell` reported failure but the gem
|
|
1133
|
+
# is in fact now installed at the latest version, reverse the verdict.
|
|
1134
|
+
# This guards against false negatives from the Terminal idle-poll
|
|
1135
|
+
# mechanism (see: 0.9.36 upgrade failure bug).
|
|
1122
1136
|
private def finish_upgrade(success, fallback_hint: "gem update openclacky")
|
|
1137
|
+
if !success && gem_actually_upgraded?
|
|
1138
|
+
Clacky::Logger.warn("[Upgrade] run_shell reported failure, but installed version matches latest — treating as success.")
|
|
1139
|
+
broadcast_all(type: "upgrade_log", line: "\n(Verified: the new version is installed — reclassifying as success.)\n")
|
|
1140
|
+
success = true
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1123
1143
|
if success
|
|
1124
1144
|
Clacky::Logger.info("[Upgrade] Success!")
|
|
1125
1145
|
broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
|
|
@@ -1131,6 +1151,22 @@ module Clacky
|
|
|
1131
1151
|
end
|
|
1132
1152
|
end
|
|
1133
1153
|
|
|
1154
|
+
# Check whether the latest published version of openclacky is already
|
|
1155
|
+
# installed locally. Used as a post-upgrade sanity check so a flaky
|
|
1156
|
+
# run_shell result doesn't mask a successful install.
|
|
1157
|
+
# Returns false on any error (conservative — don't fabricate success).
|
|
1158
|
+
private def gem_actually_upgraded?
|
|
1159
|
+
latest = fetch_latest_version_from_rubygems_api
|
|
1160
|
+
return false unless latest
|
|
1161
|
+
|
|
1162
|
+
out, exit_code = run_shell("gem list openclacky -i -v #{latest}", timeout: 30)
|
|
1163
|
+
return false unless exit_code&.zero?
|
|
1164
|
+
out.to_s.strip.downcase == "true"
|
|
1165
|
+
rescue StandardError => e
|
|
1166
|
+
Clacky::Logger.warn("[Upgrade] gem_actually_upgraded? error: #{e.message}")
|
|
1167
|
+
false
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1134
1170
|
# POST /api/restart
|
|
1135
1171
|
# Re-execs the current process so the newly installed gem version is loaded.
|
|
1136
1172
|
# Uses the absolute script path captured at startup to avoid relative-path issues.
|
|
@@ -1242,18 +1278,11 @@ module Clacky
|
|
|
1242
1278
|
# Run a shell command via the unified Terminal tool and return
|
|
1243
1279
|
# [output, exit_code] — drop-in replacement for Open3.capture2e.
|
|
1244
1280
|
#
|
|
1245
|
-
#
|
|
1246
|
-
#
|
|
1247
|
-
#
|
|
1248
|
-
#
|
|
1249
|
-
# The command is routed through the Security layer like any other
|
|
1250
|
-
# Terminal call; server-side commands (`gem ...`, `curl -fsSL ... -o ...`)
|
|
1251
|
-
# pass through unchanged.
|
|
1281
|
+
# Delegates to Terminal.run_sync which handles the idle-poll loop
|
|
1282
|
+
# internally (see its docs for why that's needed — this wrapper used
|
|
1283
|
+
# to re-implement it wrong and caused the 0.9.36 upgrade bug).
|
|
1252
1284
|
private def run_shell(command, timeout: 120)
|
|
1253
|
-
|
|
1254
|
-
output = result[:output].to_s
|
|
1255
|
-
exit_code = result[:exit_code] # nil when the session is still running
|
|
1256
|
-
[output, exit_code]
|
|
1285
|
+
Clacky::Tools::Terminal.run_sync(command, timeout: timeout)
|
|
1257
1286
|
end
|
|
1258
1287
|
|
|
1259
1288
|
# ── Channel API ───────────────────────────────────────────────────────────
|
|
@@ -1915,18 +1944,32 @@ module Clacky
|
|
|
1915
1944
|
# Lookup by id only. No id means a brand-new model — we mint one.
|
|
1916
1945
|
existing = m["id"] && existing_by_id[m["id"]]
|
|
1917
1946
|
|
|
1918
|
-
# Resolve api_key
|
|
1919
|
-
|
|
1920
|
-
|
|
1947
|
+
# Resolve api_key with THREE cases (ordered, fail-safe):
|
|
1948
|
+
# 1. Incoming key is the masked placeholder ("sk-ab12****...5678")
|
|
1949
|
+
# → user didn't retype; keep the stored key.
|
|
1950
|
+
# 2. Incoming key is missing/blank AND we have an existing model
|
|
1951
|
+
# → the browser omitted api_key for non-edited rows; keep the
|
|
1952
|
+
# stored key. This is the critical 0.9.37 fix — without it,
|
|
1953
|
+
# saving one model silently wiped api_keys of all others,
|
|
1954
|
+
# because the frontend only ever hydrates api_key for the
|
|
1955
|
+
# row currently being edited (/api/config returns only
|
|
1956
|
+
# api_key_masked, never api_key).
|
|
1957
|
+
# 3. Otherwise: user typed a new key (or this is a brand-new
|
|
1958
|
+
# model); use the incoming value.
|
|
1959
|
+
incoming_key = m["api_key"].to_s
|
|
1960
|
+
api_key = if incoming_key.include?("****")
|
|
1961
|
+
existing&.dig("api_key").to_s
|
|
1962
|
+
elsif incoming_key.empty? && existing
|
|
1963
|
+
existing["api_key"].to_s
|
|
1921
1964
|
else
|
|
1922
|
-
|
|
1965
|
+
incoming_key
|
|
1923
1966
|
end
|
|
1924
1967
|
|
|
1925
1968
|
{
|
|
1926
1969
|
"id" => (existing && existing["id"]) || SecureRandom.uuid,
|
|
1927
1970
|
"model" => m["model"].to_s,
|
|
1928
1971
|
"base_url" => m["base_url"].to_s,
|
|
1929
|
-
"api_key" => api_key
|
|
1972
|
+
"api_key" => api_key,
|
|
1930
1973
|
"anthropic_format" => m["anthropic_format"] || false,
|
|
1931
1974
|
"type" => m["type"]
|
|
1932
1975
|
}.tap { |h| h.delete("type") if h["type"].nil? || h["type"].to_s.empty? }
|
|
@@ -143,11 +143,22 @@ module Clacky
|
|
|
143
143
|
# nil = no source filter (all sessions)
|
|
144
144
|
# profile: "general"|"coding"|nil
|
|
145
145
|
# nil = no agent_profile filter
|
|
146
|
-
# limit: max sessions to return
|
|
146
|
+
# limit: max sessions to return (applies to NON-PINNED only; see below)
|
|
147
147
|
# before: ISO8601 cursor — only sessions with created_at < before
|
|
148
|
+
# (also applies to NON-PINNED only; pinned items are a separate
|
|
149
|
+
# logical section, they should never be paginated away)
|
|
150
|
+
# include_pinned: when true (default), all matching pinned sessions are
|
|
151
|
+
# always returned on the FIRST page (before == nil) regardless
|
|
152
|
+
# of limit. Subsequent pages (before set) contain only
|
|
153
|
+
# non-pinned sessions. This guarantees that users who pinned
|
|
154
|
+
# an old session always see it at the top of the sidebar,
|
|
155
|
+
# even if many newer sessions exist.
|
|
156
|
+
#
|
|
157
|
+
# Ordering of the returned array:
|
|
158
|
+
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
148
159
|
#
|
|
149
160
|
# source and profile are orthogonal — either can be nil independently.
|
|
150
|
-
def list(limit: nil, before: nil, q: nil, date: nil, type: nil)
|
|
161
|
+
def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
|
|
151
162
|
return [] unless @session_manager
|
|
152
163
|
|
|
153
164
|
live = @mutex.synchronize do
|
|
@@ -185,10 +196,26 @@ module Clacky
|
|
|
185
196
|
}
|
|
186
197
|
end
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
# ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
|
|
200
|
+
# Pinned sessions bypass pagination entirely so an old pinned session
|
|
201
|
+
# never falls off the first page just because newer sessions exist.
|
|
202
|
+
# (Regression fix for 0.9.37: previously `all_sessions` was only
|
|
203
|
+
# sorted by created_at and `limit` cut off old pinned rows, making
|
|
204
|
+
# them invisible until the user clicked "load more".)
|
|
205
|
+
pinned, non_pinned = all.partition { |s| s[:pinned] }
|
|
206
|
+
|
|
207
|
+
# `before` cursor ONLY applies to non-pinned (paginated) sessions.
|
|
208
|
+
non_pinned = non_pinned.select { |s| (s[:created_at] || "") < before } if before
|
|
209
|
+
non_pinned = non_pinned.first(limit) if limit
|
|
210
|
+
|
|
211
|
+
# Pinned section: only included on the first page (before == nil) so
|
|
212
|
+
# "load more" responses don't re-send them. On first page, return ALL
|
|
213
|
+
# matching pinned sessions regardless of limit.
|
|
214
|
+
pinned_section = (include_pinned && before.nil?) ? pinned : []
|
|
215
|
+
|
|
216
|
+
ordered = pinned_section + non_pinned
|
|
190
217
|
|
|
191
|
-
|
|
218
|
+
ordered.map do |s|
|
|
192
219
|
id = s[:session_id]
|
|
193
220
|
ls = live[id]
|
|
194
221
|
{
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -44,7 +44,10 @@ module Clacky
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
begin
|
|
47
|
-
|
|
47
|
+
# Scrub invalid UTF-8 bytes at read time — otherwise editing a file
|
|
48
|
+
# that contains non-UTF-8 bytes would poison history / error messages
|
|
49
|
+
# and cause JSON.generate to fail during replay.
|
|
50
|
+
content = safe_utf8(File.read(path))
|
|
48
51
|
|
|
49
52
|
# Find matching string using layered strategy (shared with preview)
|
|
50
53
|
match_result = Utils::StringMatcher.find_match(content, old_string)
|
|
@@ -127,6 +130,13 @@ module Clacky
|
|
|
127
130
|
replacements = result[:replacements] || result["replacements"] || 1
|
|
128
131
|
"Modified #{replacements} occurrence#{replacements > 1 ? "s" : ""}"
|
|
129
132
|
end
|
|
133
|
+
|
|
134
|
+
# Scrub invalid UTF-8 byte sequences (see file_reader.rb for rationale).
|
|
135
|
+
private def safe_utf8(str)
|
|
136
|
+
return str if str.nil?
|
|
137
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
138
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
139
|
+
end
|
|
130
140
|
end
|
|
131
141
|
end
|
|
132
142
|
end
|
|
@@ -86,8 +86,10 @@ module Clacky
|
|
|
86
86
|
}
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
# Read text file with optional line range
|
|
90
|
-
|
|
89
|
+
# Read text file with optional line range.
|
|
90
|
+
# Scrub invalid UTF-8 bytes (e.g. GBK-encoded files) so downstream
|
|
91
|
+
# JSON.generate / history persistence won't blow up later.
|
|
92
|
+
all_lines = File.readlines(expanded_path).map! { |line| safe_utf8(line) }
|
|
91
93
|
total_lines = all_lines.size
|
|
92
94
|
|
|
93
95
|
# Calculate start index (convert 1-indexed to 0-indexed)
|
|
@@ -313,7 +315,11 @@ module Clacky
|
|
|
313
315
|
# List first-level directory contents (files and directories)
|
|
314
316
|
private def list_directory_contents(path)
|
|
315
317
|
begin
|
|
316
|
-
|
|
318
|
+
# Scrub entry names — filenames on disk may contain non-UTF-8 bytes
|
|
319
|
+
# (e.g. GBK/Shift-JIS names on macOS/Linux) which would poison history.
|
|
320
|
+
entries = Dir.entries(path)
|
|
321
|
+
.map { |entry| safe_utf8(entry) }
|
|
322
|
+
.reject { |entry| entry == "." || entry == ".." }
|
|
317
323
|
|
|
318
324
|
# Separate files and directories
|
|
319
325
|
files = []
|
|
@@ -353,6 +359,16 @@ module Clacky
|
|
|
353
359
|
}
|
|
354
360
|
end
|
|
355
361
|
end
|
|
362
|
+
|
|
363
|
+
# Scrub invalid UTF-8 byte sequences so the result survives
|
|
364
|
+
# JSON.generate (session replay, API responses).
|
|
365
|
+
# Invalid bytes are replaced with U+FFFD (�). Valid UTF-8 is
|
|
366
|
+
# returned untouched via the fast path.
|
|
367
|
+
private def safe_utf8(str)
|
|
368
|
+
return str if str.nil?
|
|
369
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
370
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
371
|
+
end
|
|
356
372
|
end
|
|
357
373
|
end
|
|
358
374
|
end
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -84,6 +84,7 @@ module Clacky
|
|
|
84
84
|
always_ignored_dirs = Clacky::Utils::FileIgnoreHelper::ALWAYS_IGNORED_DIRS
|
|
85
85
|
|
|
86
86
|
all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
|
|
87
|
+
.map { |p| p.encoding == Encoding::UTF_8 && p.valid_encoding? ? p : p.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") }
|
|
87
88
|
.reject { |path| File.directory?(path) }
|
|
88
89
|
.reject { |path| path.end_with?(".", "..") }
|
|
89
90
|
.reject do |path|
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -271,8 +271,10 @@ module Clacky
|
|
|
271
271
|
def search_file(file, regex, context_lines, max_matches)
|
|
272
272
|
matches = []
|
|
273
273
|
|
|
274
|
-
# Use File.foreach for memory-efficient line-by-line reading
|
|
275
|
-
|
|
274
|
+
# Use File.foreach for memory-efficient line-by-line reading.
|
|
275
|
+
# Scrub invalid UTF-8 bytes so results survive JSON encoding.
|
|
276
|
+
File.foreach(file, chomp: true).with_index do |raw_line, index|
|
|
277
|
+
line = safe_utf8(raw_line)
|
|
276
278
|
# Stop if we have enough matches for this file
|
|
277
279
|
break if matches.length >= max_matches
|
|
278
280
|
|
|
@@ -302,7 +304,7 @@ module Clacky
|
|
|
302
304
|
|
|
303
305
|
# Get context lines around a match
|
|
304
306
|
def get_line_context(file, match_index, context_lines)
|
|
305
|
-
lines = File.readlines(file, chomp: true)
|
|
307
|
+
lines = File.readlines(file, chomp: true).map! { |l| safe_utf8(l) }
|
|
306
308
|
start_line = [0, match_index - context_lines].max
|
|
307
309
|
end_line = [lines.length - 1, match_index + context_lines].min
|
|
308
310
|
|
|
@@ -325,6 +327,13 @@ module Clacky
|
|
|
325
327
|
rescue StandardError
|
|
326
328
|
nil
|
|
327
329
|
end
|
|
330
|
+
|
|
331
|
+
# Scrub invalid UTF-8 byte sequences (see file_reader.rb for rationale).
|
|
332
|
+
private def safe_utf8(str)
|
|
333
|
+
return str if str.nil?
|
|
334
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
335
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
336
|
+
end
|
|
328
337
|
end
|
|
329
338
|
end
|
|
330
339
|
end
|
|
@@ -147,6 +147,71 @@ module Clacky
|
|
|
147
147
|
Clacky::Tools::Security.command_safe_for_auto_execution?(command)
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# ---------------------------------------------------------------------
|
|
151
|
+
# Internal Ruby API — synchronous capture
|
|
152
|
+
# ---------------------------------------------------------------------
|
|
153
|
+
#
|
|
154
|
+
# Run a shell command and BLOCK until it terminates, returning
|
|
155
|
+
# [output, exit_code]. Drop-in replacement for Open3.capture2e that
|
|
156
|
+
# goes through the same PTY + login-shell + Security pipeline used by
|
|
157
|
+
# the AI-facing tool (so rbenv/mise shims and gem mirrors work).
|
|
158
|
+
#
|
|
159
|
+
# Why this exists separately from #execute:
|
|
160
|
+
#
|
|
161
|
+
# `execute` may return early with a :session_id the moment output
|
|
162
|
+
# goes idle for DEFAULT_IDLE_MS (3s) — this is intentional for AI
|
|
163
|
+
# agents (they can inspect progress, inject input, decide to kill).
|
|
164
|
+
# Ruby callers like the HTTP server's upgrade flow only care about
|
|
165
|
+
# "did it finish, with what output, what exit code" — they need
|
|
166
|
+
# synchronous semantics. Previously each caller re-implemented the
|
|
167
|
+
# poll loop (and 0.9.36's run_shell forgot to, causing the upgrade
|
|
168
|
+
# failure bug).
|
|
169
|
+
#
|
|
170
|
+
# NOT exposed in tool_parameters — AI agents cannot invoke this.
|
|
171
|
+
#
|
|
172
|
+
# @param command [String] the shell command to run
|
|
173
|
+
# @param timeout [Integer] per-poll timeout AND the basis for the
|
|
174
|
+
# overall deadline (deadline = timeout + 60s)
|
|
175
|
+
# @param cwd [String] optional working directory
|
|
176
|
+
# @param env [Hash] optional env overrides
|
|
177
|
+
# @return [Array(String, Integer|nil)] [output, exit_code].
|
|
178
|
+
# exit_code is nil only if the overall deadline was hit and
|
|
179
|
+
# the session had to be force-killed.
|
|
180
|
+
def self.run_sync(command, timeout: 120, cwd: nil, env: nil)
|
|
181
|
+
terminal = new
|
|
182
|
+
result = terminal.execute(
|
|
183
|
+
command: command,
|
|
184
|
+
timeout: timeout,
|
|
185
|
+
cwd: cwd,
|
|
186
|
+
env: env,
|
|
187
|
+
)
|
|
188
|
+
output = result[:output].to_s
|
|
189
|
+
|
|
190
|
+
# Hard deadline in wall-clock terms — a genuinely stuck command
|
|
191
|
+
# must terminate. Each individual poll still carries `timeout`.
|
|
192
|
+
deadline = Time.now + timeout.to_i + 60
|
|
193
|
+
|
|
194
|
+
while result[:exit_code].nil? && result[:session_id] && Time.now < deadline
|
|
195
|
+
result = terminal.execute(
|
|
196
|
+
session_id: result[:session_id],
|
|
197
|
+
input: "",
|
|
198
|
+
timeout: timeout,
|
|
199
|
+
)
|
|
200
|
+
output += result[:output].to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Deadline exceeded — best-effort cleanup so the session doesn't leak.
|
|
204
|
+
if result[:exit_code].nil? && result[:session_id]
|
|
205
|
+
begin
|
|
206
|
+
terminal.execute(session_id: result[:session_id], kill: true)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
# swallow — cleanup is best-effort
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[output, result[:exit_code]]
|
|
213
|
+
end
|
|
214
|
+
|
|
150
215
|
# ---------------------------------------------------------------------
|
|
151
216
|
# 1) Start a new command
|
|
152
217
|
# ---------------------------------------------------------------------
|
|
@@ -105,6 +105,42 @@ module Clacky
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
|
|
108
|
+
# DeepSeek V4 models
|
|
109
|
+
# Source: https://api-docs.deepseek.com/quick_start/pricing (USD / 1M tokens)
|
|
110
|
+
# DeepSeek billing model:
|
|
111
|
+
# - "cache miss input" = regular prompt_tokens rate
|
|
112
|
+
# - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
|
|
113
|
+
# - No tiered pricing (single rate regardless of context length)
|
|
114
|
+
"deepseek-v4-flash" => {
|
|
115
|
+
input: {
|
|
116
|
+
default: 0.14, # $0.14/MTok cache miss
|
|
117
|
+
over_200k: 0.14 # no tiered pricing
|
|
118
|
+
},
|
|
119
|
+
output: {
|
|
120
|
+
default: 0.28, # $0.28/MTok
|
|
121
|
+
over_200k: 0.28
|
|
122
|
+
},
|
|
123
|
+
cache: {
|
|
124
|
+
write: 0.14, # DeepSeek doesn't charge extra for writes; bill at miss rate
|
|
125
|
+
read: 0.028 # $0.028/MTok cache hit
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
"deepseek-v4-pro" => {
|
|
130
|
+
input: {
|
|
131
|
+
default: 1.74, # $1.74/MTok cache miss
|
|
132
|
+
over_200k: 1.74
|
|
133
|
+
},
|
|
134
|
+
output: {
|
|
135
|
+
default: 3.48, # $3.48/MTok
|
|
136
|
+
over_200k: 3.48
|
|
137
|
+
},
|
|
138
|
+
cache: {
|
|
139
|
+
write: 1.74, # no separate write charge; bill at miss rate
|
|
140
|
+
read: 0.145 # $0.145/MTok cache hit
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
108
144
|
# Default fallback pricing (conservative estimates)
|
|
109
145
|
"default" => {
|
|
110
146
|
input: {
|
|
@@ -238,6 +274,15 @@ module Clacky
|
|
|
238
274
|
"claude-3-5-sonnet-20240620"
|
|
239
275
|
when /claude-3-5-haiku-20241022/i
|
|
240
276
|
"claude-3-5-haiku-20241022"
|
|
277
|
+
when /deepseek-v4-pro/i, /deepseek.*v4.*pro/i
|
|
278
|
+
"deepseek-v4-pro"
|
|
279
|
+
when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
|
|
280
|
+
"deepseek-v4-flash"
|
|
281
|
+
# Legacy aliases: deepseek-chat and deepseek-reasoner are being
|
|
282
|
+
# deprecated on 2026-07-24 and map to deepseek-v4-flash's
|
|
283
|
+
# non-thinking / thinking modes respectively. Bill at flash rates.
|
|
284
|
+
when /^deepseek-chat$/i, /^deepseek-reasoner$/i
|
|
285
|
+
"deepseek-v4-flash"
|
|
241
286
|
else
|
|
242
287
|
"default"
|
|
243
288
|
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/auth.js
CHANGED
|
@@ -1,101 +1,150 @@
|
|
|
1
|
-
// ── auth.js — Access key authentication guard ──────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Responsibilities:
|
|
4
|
-
// - Prompt user for access key when server requires one
|
|
5
|
-
// - Cache key in localStorage; pass via Authorization header
|
|
6
|
-
// - Block app boot until auth passes
|
|
7
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
8
1
|
const Auth = (() => {
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
3
|
+
const COOKIE_NAME = 'clacky_access_key';
|
|
4
|
+
const STORAGE_KEY = 'clacky_access_key';
|
|
5
|
+
const PROBE_ENDPOINT = '/api/sessions?limit=1';
|
|
6
|
+
const MAX_PROMPT_TRIES = 3;
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
const PROBE = Object.freeze({
|
|
9
|
+
OK: 'ok',
|
|
10
|
+
UNAUTHORIZED: 'unauthorized',
|
|
11
|
+
SERVER_ERR: 'server_error',
|
|
12
|
+
NETWORK_ERR: 'network_error',
|
|
13
|
+
});
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// Always probe first — server may not require auth at all (e.g. localhost).
|
|
22
|
-
try {
|
|
23
|
-
const r = await fetch('/api/sessions?limit=1', {
|
|
24
|
-
headers: key ? { 'Authorization': `Bearer ${key}` } : {}
|
|
25
|
-
});
|
|
15
|
+
// ── Module state ───────────────────────────────────────────────────────
|
|
16
|
+
let _authCheckPromise = null;
|
|
17
|
+
let _authPassed = false;
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
// ── Storage helpers ────────────────────────────────────────────────────
|
|
20
|
+
const Cookie = {
|
|
21
|
+
set(key) {
|
|
22
|
+
const secure = location.protocol === 'https:' ? '; Secure' : '';
|
|
23
|
+
document.cookie =
|
|
24
|
+
`${COOKIE_NAME}=${encodeURIComponent(key)}; path=/; SameSite=Strict${secure}`;
|
|
25
|
+
},
|
|
26
|
+
clear() {
|
|
27
|
+
document.cookie =
|
|
28
|
+
`${COOKIE_NAME}=; path=/; max-age=0; SameSite=Strict`;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
function _getStoredKey() {
|
|
33
|
+
return (
|
|
34
|
+
localStorage.getItem(STORAGE_KEY) ||
|
|
35
|
+
new URLSearchParams(location.search).get('access_key') ||
|
|
36
|
+
null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// ── Auth probe ─────────────────────────────────────────────────────────
|
|
41
|
+
async function _probe() {
|
|
42
|
+
try {
|
|
43
|
+
const r = await fetch(PROBE_ENDPOINT);
|
|
44
|
+
if (r.ok) return PROBE.OK;
|
|
45
|
+
if (r.status === 401) return PROBE.UNAUTHORIZED;
|
|
46
|
+
return PROBE.SERVER_ERR;
|
|
41
47
|
} catch {
|
|
42
|
-
|
|
43
|
-
_authPassed = true;
|
|
44
|
-
return true;
|
|
48
|
+
return PROBE.NETWORK_ERR;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
// ── Prompt helper ──────────────────────────────────────────────────────
|
|
53
|
+
async function _askUserForKey() {
|
|
49
54
|
const message = (typeof I18n !== 'undefined')
|
|
50
55
|
? I18n.t('auth.accessKeyRequired')
|
|
51
56
|
: 'Access key required:';
|
|
52
57
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
: prompt(message);
|
|
58
|
+
const el = document.getElementById('prompt-modal-input');
|
|
59
|
+
if (el) el.type = 'password';
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
try {
|
|
62
|
+
const input = (typeof Modal !== 'undefined' && Modal.prompt)
|
|
63
|
+
? await Modal.prompt(message)
|
|
64
|
+
: prompt(message);
|
|
65
|
+
return input?.trim() || null;
|
|
66
|
+
} finally {
|
|
67
|
+
if (el) el.type = 'text';
|
|
60
68
|
}
|
|
69
|
+
}
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// ── Core flow ──────────────────────────────────────────────────────────
|
|
72
|
+
async function _doCheck() {
|
|
73
|
+
const existing = _getStoredKey();
|
|
74
|
+
if (existing) Cookie.set(existing); // seed cookie before probe
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
headers: { 'Authorization': `Bearer ${trimmed}` }
|
|
69
|
-
});
|
|
70
|
-
if (r.ok) {
|
|
71
|
-
_authPassed = true;
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
// Still wrong — clear storage and restart the auth flow.
|
|
75
|
-
localStorage.removeItem('clacky_access_key');
|
|
76
|
-
_authCheckPromise = null;
|
|
77
|
-
return check();
|
|
78
|
-
} catch {
|
|
76
|
+
const result = await _probe();
|
|
77
|
+
|
|
78
|
+
if (result === PROBE.OK) {
|
|
79
79
|
_authPassed = true;
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
if (result === PROBE.UNAUTHORIZED) {
|
|
84
|
+
Cookie.clear();
|
|
85
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
86
|
+
return _promptAndRetry();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Server/network error — let app proceed
|
|
90
|
+
_authPassed = true;
|
|
91
|
+
return true;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
async function _promptAndRetry() {
|
|
95
|
+
for (let attempt = 1; attempt <= MAX_PROMPT_TRIES; attempt++) {
|
|
96
|
+
const key = await _askUserForKey();
|
|
97
|
+
if (!key) {
|
|
98
|
+
_authPassed = false;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Cookie.set(key);
|
|
103
|
+
const result = await _probe();
|
|
104
|
+
|
|
105
|
+
if (result === PROBE.OK) {
|
|
106
|
+
localStorage.setItem(STORAGE_KEY, key); // persist only after success
|
|
107
|
+
_authPassed = true;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (result !== PROBE.UNAUTHORIZED) {
|
|
112
|
+
_authPassed = true; // transient — proceed
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Cookie.clear(); // wrong key → try again
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_authPassed = false;
|
|
120
|
+
return false;
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
// ── Public API (compatible with the original ws.js/app.js usage) ───────
|
|
124
|
+
function check() {
|
|
125
|
+
if (!_authCheckPromise) _authCheckPromise = _doCheck();
|
|
126
|
+
return _authCheckPromise;
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
return {
|
|
95
130
|
check,
|
|
96
|
-
|
|
131
|
+
|
|
132
|
+
// Returns an Authorization header object, or {} if no key present.
|
|
133
|
+
getHeaders() {
|
|
134
|
+
const k = _getStoredKey();
|
|
135
|
+
return k ? { Authorization: `Bearer ${k}` } : {};
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
// Returns the raw key (or null). Used by ws.js for WebSocket URLs.
|
|
97
139
|
getKey: _getStoredKey,
|
|
98
|
-
|
|
99
|
-
|
|
140
|
+
|
|
141
|
+
// Clears auth state so check() will re-probe on next call.
|
|
142
|
+
reset() {
|
|
143
|
+
_authCheckPromise = null;
|
|
144
|
+
_authPassed = false;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// Read-only getter: `Auth.passed` (not a function call).
|
|
148
|
+
get passed() { return _authPassed; },
|
|
100
149
|
};
|
|
101
150
|
})();
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -1027,8 +1027,14 @@ const Sessions = (() => {
|
|
|
1027
1027
|
Sessions.renderList();
|
|
1028
1028
|
|
|
1029
1029
|
try {
|
|
1030
|
-
// Cursor: oldest created_at in the current list
|
|
1030
|
+
// Cursor: oldest created_at in the current list, EXCLUDING pinned
|
|
1031
|
+
// sessions. The backend always returns ALL pinned sessions on the
|
|
1032
|
+
// first page (they bypass pagination), so their created_at is
|
|
1033
|
+
// irrelevant for cursor calculation. Including them here would
|
|
1034
|
+
// cause the cursor to jump too far back and skip sessions between
|
|
1035
|
+
// the oldest pinned one and the real last-loaded non-pinned row.
|
|
1031
1036
|
const oldest = _sessions.reduce((min, s) => {
|
|
1037
|
+
if (s.pinned) return min; // ignore pinned
|
|
1032
1038
|
if (!s.created_at) return min;
|
|
1033
1039
|
return (!min || s.created_at < min) ? s.created_at : min;
|
|
1034
1040
|
}, null);
|