openclacky 1.1.0 → 1.1.1
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 +16 -0
- data/lib/clacky/agent/skill_manager.rb +18 -5
- data/lib/clacky/brand_config.rb +68 -15
- data/lib/clacky/cli.rb +9 -17
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
- data/lib/clacky/server/http_server.rb +7 -0
- data/lib/clacky/tools/terminal.rb +11 -0
- data/lib/clacky/ui2/components/input_area.rb +10 -1
- data/lib/clacky/ui2/components/todo_area.rb +22 -2
- data/lib/clacky/ui2/layout_manager.rb +70 -14
- data/lib/clacky/ui2/progress_handle.rb +9 -0
- data/lib/clacky/ui2/ui_controller.rb +43 -5
- data/lib/clacky/utils/logger.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/i18n.js +15 -6
- data/lib/clacky/web/index.html +5 -5
- data/lib/clacky/web/sessions.js +19 -10
- data/scripts/build/src/install.sh.cc +15 -5
- data/scripts/install.sh +15 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22bdc5132636582b787ebc49b43157871d15cfc0519a8ba93bc69fcfa9b58a2b
|
|
4
|
+
data.tar.gz: 97c40c43d3ae3252f81b1d3b92c89812cf28bdb181094d1fca6f5fea8ea9daa9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c195480e06945b0a09e031498e5f1f4df5d5bb661ef123cf139cd570b33522a47475b8d698e3999a44de8979884e7e8f4dcc3adc424896965ad69cbac83902d
|
|
7
|
+
data.tar.gz: a151a7178e6541880ddad41930adf0e652571bb732cd82ba6b6c2de8e9d880e587a1e680d2c76a3dcb8f9609152f092b23a38afb1c443f8082b1f081d34d81da
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.1] - 2026-05-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **WeChat SendQueue with batching, throttling, and retry.** Messages sent to multiple WeChat official account users are now queued, batched (up to 100 recipients per call), throttled to 1 batch/second, and automatically retried on failure — preventing 45007 rate-limit errors during broadcasts. (#127)
|
|
12
|
+
- **Session ID in TUI session bar.** The terminal UI session bar now displays the session ID alongside the session name, making it easy to identify sessions when cross-referencing with logs or Web UI.
|
|
13
|
+
- **TUI todo clean-up on task completion.** Completed todos are now removed from the terminal display when a task finishes, keeping the TUI uncluttered. (#94)
|
|
14
|
+
|
|
15
|
+
### Improved
|
|
16
|
+
- **Brand skills persist across same-brand upgrades.** Brand skills are no longer removed and re-downloaded when the brand stays the same after an upgrade — eliminating unnecessary network calls and keeping skill state stable.
|
|
17
|
+
- **Ruby 2.6 install reliability.** The installer now pre-installs rouge 3.30.0 before `gem install` and retries with a pinned version on Ruby 2.6, avoiding dependency resolution failures on older macOS system Ruby.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **TUI progress bar flicker.** The progress bar in terminal mode no longer flashes when updating rapidly, providing a smoother visual experience.
|
|
21
|
+
- **Xcode command auto-install loop.** The agent no longer gets stuck in a loop trying to auto-install missing Xcode command-line tools.
|
|
22
|
+
- **Brand license warning after 3-day idle.** Fixed a spurious license warning that appeared on startup after the server had been idle for 3 days.
|
|
23
|
+
|
|
8
24
|
## [1.1.0] - 2026-05-15
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -91,6 +91,20 @@ module Clacky
|
|
|
91
91
|
# Keeps context tokens bounded regardless of how many skills are installed.
|
|
92
92
|
MAX_CONTEXT_SKILLS = 30
|
|
93
93
|
|
|
94
|
+
# Process-wide deduper for the "skill context limit" warning so that
|
|
95
|
+
# every newly constructed Agent (sub-agents, retries, web turns…) doesn't
|
|
96
|
+
# re-emit the same line.
|
|
97
|
+
@skill_limit_warned_signatures = {}
|
|
98
|
+
@skill_limit_warn_mutex = Mutex.new
|
|
99
|
+
|
|
100
|
+
def self.warn_skill_limit_once(signature, &block)
|
|
101
|
+
@skill_limit_warn_mutex.synchronize do
|
|
102
|
+
return if @skill_limit_warned_signatures[signature]
|
|
103
|
+
@skill_limit_warned_signatures[signature] = true
|
|
104
|
+
end
|
|
105
|
+
block.call
|
|
106
|
+
end
|
|
107
|
+
|
|
94
108
|
# Generate skill context - loads all auto-invocable skills allowed by the agent profile
|
|
95
109
|
# @return [String] Skill context to add to system prompt
|
|
96
110
|
def build_skill_context
|
|
@@ -103,17 +117,16 @@ module Clacky
|
|
|
103
117
|
auto_invocable = all_skills.select(&:model_invocation_allowed?)
|
|
104
118
|
|
|
105
119
|
# Enforce system prompt injection limit to control token usage.
|
|
106
|
-
# Warn
|
|
107
|
-
#
|
|
108
|
-
#
|
|
120
|
+
# Warn at most once per process per dropped-set signature — build_skill_context
|
|
121
|
+
# runs on every system-prompt assembly and is invoked from many short-lived
|
|
122
|
+
# Agent instances (sub-agents, web turns…), so per-instance dedup wasn't enough.
|
|
109
123
|
if auto_invocable.size > MAX_CONTEXT_SKILLS
|
|
110
124
|
kept = auto_invocable.first(MAX_CONTEXT_SKILLS)
|
|
111
125
|
dropped = auto_invocable.drop(MAX_CONTEXT_SKILLS)
|
|
112
126
|
dropped_names = dropped.map(&:identifier)
|
|
113
127
|
signature = dropped_names.sort.join(",")
|
|
114
128
|
|
|
115
|
-
|
|
116
|
-
@skill_limit_warned_signature = signature
|
|
129
|
+
SkillManager.warn_skill_limit_once(signature) do
|
|
117
130
|
Clacky::Logger.warn(
|
|
118
131
|
"Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
|
|
119
132
|
"only injecting first #{MAX_CONTEXT_SKILLS} " \
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Clacky
|
|
|
44
44
|
:license_expires_at, :license_last_heartbeat, :device_id,
|
|
45
45
|
:logo_url, :support_contact, :license_user_id,
|
|
46
46
|
:support_qr_url, :theme_color, :homepage_url,
|
|
47
|
-
:distribution_last_refreshed_at
|
|
47
|
+
:distribution_last_refreshed_at, :license_last_heartbeat_failure
|
|
48
48
|
|
|
49
49
|
def initialize(attrs = {})
|
|
50
50
|
@product_name = attrs["product_name"]
|
|
@@ -66,6 +66,11 @@ module Clacky
|
|
|
66
66
|
# #refresh_distribution!). Persisted to brand.yml so 24h throttling
|
|
67
67
|
# survives restarts.
|
|
68
68
|
@distribution_last_refreshed_at = parse_time(attrs["distribution_last_refreshed_at"])
|
|
69
|
+
# Tracks when heartbeats started failing continuously. Set on a failed
|
|
70
|
+
# heartbeat (only if currently nil), cleared on a successful one.
|
|
71
|
+
# grace_period_exceeded? uses this — NOT last_heartbeat — so a user who
|
|
72
|
+
# simply hasn't run the app in days doesn't see a stale "offline" warning.
|
|
73
|
+
@license_last_heartbeat_failure = parse_time(attrs["license_last_heartbeat_failure"])
|
|
69
74
|
|
|
70
75
|
# In-memory decryption key cache: "skill_id:skill_version_id" => { key:, expires_at: }
|
|
71
76
|
# Never persisted to disk. Survives across multiple skill invocations within one session.
|
|
@@ -130,16 +135,19 @@ module Clacky
|
|
|
130
135
|
due
|
|
131
136
|
end
|
|
132
137
|
|
|
133
|
-
# Returns true when
|
|
138
|
+
# Returns true when heartbeats have been failing continuously for longer
|
|
139
|
+
# than the grace period. Only considers ACTUAL failure streaks — a user
|
|
140
|
+
# who hasn't launched the app in a week is NOT in violation, since no
|
|
141
|
+
# heartbeat attempt has actually failed.
|
|
134
142
|
def grace_period_exceeded?
|
|
135
|
-
if @
|
|
136
|
-
Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no
|
|
143
|
+
if @license_last_heartbeat_failure.nil?
|
|
144
|
+
Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no active failure streak)")
|
|
137
145
|
return false
|
|
138
146
|
end
|
|
139
147
|
|
|
140
|
-
elapsed = Time.now.utc - @
|
|
148
|
+
elapsed = Time.now.utc - @license_last_heartbeat_failure
|
|
141
149
|
exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
|
|
142
|
-
Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
|
|
150
|
+
Clacky::Logger.debug("[Brand] grace_period_exceeded? failing_since=#{@license_last_heartbeat_failure.iso8601} elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
|
|
143
151
|
exceeded
|
|
144
152
|
end
|
|
145
153
|
|
|
@@ -178,6 +186,7 @@ module Clacky
|
|
|
178
186
|
@license_user_id = nil
|
|
179
187
|
@device_id = nil
|
|
180
188
|
@distribution_last_refreshed_at = nil
|
|
189
|
+
@license_last_heartbeat_failure = nil
|
|
181
190
|
{ success: true }
|
|
182
191
|
end
|
|
183
192
|
|
|
@@ -210,9 +219,20 @@ module Clacky
|
|
|
210
219
|
data = response[:data]
|
|
211
220
|
@license_activated_at = Time.now.utc
|
|
212
221
|
@license_last_heartbeat = Time.now.utc
|
|
222
|
+
@license_last_heartbeat_failure = nil
|
|
213
223
|
@license_expires_at = parse_time(data["expires_at"])
|
|
214
224
|
server_device_id = data["device_id"].to_s.strip
|
|
215
225
|
@device_id = server_device_id unless server_device_id.empty?
|
|
226
|
+
|
|
227
|
+
# Decide whether the new key belongs to the SAME brand as the previously
|
|
228
|
+
# activated one. If yes (e.g. trial → paid), keep the installed brand
|
|
229
|
+
# skills — they are still decryptable and the user shouldn't have to
|
|
230
|
+
# re-download. If no (switching brands), wipe them.
|
|
231
|
+
prev_package_name = @package_name
|
|
232
|
+
prev_product_name = @product_name
|
|
233
|
+
new_dist = data["distribution"].is_a?(Hash) ? data["distribution"] : {}
|
|
234
|
+
same_brand = brand_identity_match?(prev_package_name, prev_product_name, new_dist)
|
|
235
|
+
|
|
216
236
|
# Clear ALL stale fields first, then apply fresh values from the new key.
|
|
217
237
|
# Order matters: reset everything before re-assigning so no old value lingers.
|
|
218
238
|
@product_name = nil
|
|
@@ -229,10 +249,10 @@ module Clacky
|
|
|
229
249
|
owner_uid = data["owner_user_id"]
|
|
230
250
|
@license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
|
|
231
251
|
apply_distribution(data["distribution"])
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
clear_brand_skills!
|
|
252
|
+
# Skills from a different brand are encrypted with that brand's keys —
|
|
253
|
+
# they cannot be decrypted with the new license and must be re-downloaded.
|
|
254
|
+
# Same-brand re-activation (trial→paid, key rotation) preserves them.
|
|
255
|
+
clear_brand_skills! unless same_brand
|
|
236
256
|
save
|
|
237
257
|
{ success: true, message: "License activated successfully!", product_name: @product_name,
|
|
238
258
|
user_id: @license_user_id, data: data }
|
|
@@ -258,14 +278,19 @@ module Clacky
|
|
|
258
278
|
|
|
259
279
|
# Always derive product_name fresh from the key in mock mode,
|
|
260
280
|
# so switching keys produces a different brand each time.
|
|
261
|
-
user_id
|
|
262
|
-
|
|
281
|
+
user_id = parse_user_id_from_key(@license_key)
|
|
282
|
+
new_product_name = "Brand#{user_id}"
|
|
283
|
+
prev_product_name = @product_name
|
|
284
|
+
same_brand = brand_identity_match?(@package_name, prev_product_name,
|
|
285
|
+
{ "product_name" => new_product_name })
|
|
286
|
+
@product_name = new_product_name
|
|
263
287
|
|
|
264
288
|
@license_activated_at = Time.now.utc
|
|
265
289
|
@license_last_heartbeat = Time.now.utc
|
|
290
|
+
@license_last_heartbeat_failure = nil
|
|
266
291
|
@license_expires_at = Time.now.utc + (365 * 86_400) # 1 year from now
|
|
267
|
-
#
|
|
268
|
-
clear_brand_skills!
|
|
292
|
+
# Same-brand re-activation preserves installed skills; switching brands wipes them.
|
|
293
|
+
clear_brand_skills! unless same_brand
|
|
269
294
|
save
|
|
270
295
|
|
|
271
296
|
{
|
|
@@ -306,13 +331,16 @@ module Clacky
|
|
|
306
331
|
|
|
307
332
|
if response[:success]
|
|
308
333
|
@license_last_heartbeat = Time.now.utc
|
|
334
|
+
@license_last_heartbeat_failure = nil
|
|
309
335
|
@license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
|
|
310
336
|
apply_distribution(response[:data]["distribution"])
|
|
311
337
|
save
|
|
312
338
|
Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
|
|
313
339
|
{ success: true, message: "Heartbeat OK" }
|
|
314
340
|
else
|
|
315
|
-
|
|
341
|
+
@license_last_heartbeat_failure ||= Time.now.utc
|
|
342
|
+
save
|
|
343
|
+
Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]} (failing_since=#{@license_last_heartbeat_failure.iso8601})")
|
|
316
344
|
{ success: false, message: response[:error] || "Heartbeat failed" }
|
|
317
345
|
end
|
|
318
346
|
end
|
|
@@ -1134,6 +1162,7 @@ module Clacky
|
|
|
1134
1162
|
# Persist user_id so user-licensed features remain available across restarts
|
|
1135
1163
|
data["license_user_id"] = @license_user_id if @license_user_id && !@license_user_id.strip.empty?
|
|
1136
1164
|
data["distribution_last_refreshed_at"] = @distribution_last_refreshed_at.iso8601 if @distribution_last_refreshed_at
|
|
1165
|
+
data["license_last_heartbeat_failure"] = @license_last_heartbeat_failure.iso8601 if @license_last_heartbeat_failure
|
|
1137
1166
|
YAML.dump(data)
|
|
1138
1167
|
end
|
|
1139
1168
|
|
|
@@ -1155,6 +1184,30 @@ module Clacky
|
|
|
1155
1184
|
self.class.version_older?(installed, latest)
|
|
1156
1185
|
end
|
|
1157
1186
|
|
|
1187
|
+
# Decide whether a re-activation key targets the same brand as the
|
|
1188
|
+
# currently-loaded one, so we know whether installed brand skills can stay.
|
|
1189
|
+
#
|
|
1190
|
+
# Identity preference, in order:
|
|
1191
|
+
# 1. package_name — bundle identifier, the strongest brand signal
|
|
1192
|
+
# 2. product_name — display name fallback when package_name is missing
|
|
1193
|
+
#
|
|
1194
|
+
# If neither is present on either side, treat as different brand (safe default:
|
|
1195
|
+
# wipe skills) since we can't confirm continuity.
|
|
1196
|
+
private def brand_identity_match?(prev_package_name, prev_product_name, new_dist)
|
|
1197
|
+
new_dist = {} unless new_dist.is_a?(Hash)
|
|
1198
|
+
new_pkg = new_dist["package_name"].to_s.strip
|
|
1199
|
+
old_pkg = prev_package_name.to_s.strip
|
|
1200
|
+
if !new_pkg.empty? && !old_pkg.empty?
|
|
1201
|
+
return new_pkg == old_pkg
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
new_prod = new_dist["product_name"].to_s.strip
|
|
1205
|
+
old_prod = prev_product_name.to_s.strip
|
|
1206
|
+
return new_prod == old_prod if !new_prod.empty? && !old_prod.empty?
|
|
1207
|
+
|
|
1208
|
+
false
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1158
1211
|
# Apply distribution fields from API response.
|
|
1159
1212
|
# Updates product_name, package_name, logo_url, support_contact, support_qr_url,
|
|
1160
1213
|
# theme_color, and homepage_url from the distribution hash.
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -365,19 +365,12 @@ module Clacky
|
|
|
365
365
|
return
|
|
366
366
|
end
|
|
367
367
|
|
|
368
|
+
# Heartbeat is fire-and-forget — startup must never block on the
|
|
369
|
+
# license server. The grace_period_exceeded? check below now keys off
|
|
370
|
+
# license_last_heartbeat_failure (set on a failed heartbeat, cleared
|
|
371
|
+
# on success), so a user who simply hasn't run the app for >3 days
|
|
372
|
+
# no longer sees a false "offline" warning on first launch.
|
|
368
373
|
if brand.heartbeat_due?
|
|
369
|
-
# Fire-and-forget heartbeat in a background thread.
|
|
370
|
-
#
|
|
371
|
-
# Rationale: a slow/unreachable license server would otherwise block
|
|
372
|
-
# CLI startup for up to ~92s (2 hosts × 2 attempts × 23s timeout)
|
|
373
|
-
# before the user sees the prompt. Heartbeat is "best-effort" by
|
|
374
|
-
# design — its only job is to refresh `last_heartbeat` / `expires_at`
|
|
375
|
-
# on disk for the next run's grace-period calculation. Missing a
|
|
376
|
-
# single heartbeat is harmless; the next launch will try again.
|
|
377
|
-
#
|
|
378
|
-
# Consequence: if this run was going to trigger the
|
|
379
|
-
# grace_period_exceeded warning, the user will see it on the *next*
|
|
380
|
-
# launch instead of this one. Acceptable trade-off.
|
|
381
374
|
Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
|
|
382
375
|
Thread.new do
|
|
383
376
|
begin
|
|
@@ -395,10 +388,6 @@ module Clacky
|
|
|
395
388
|
Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
|
|
396
389
|
end
|
|
397
390
|
|
|
398
|
-
# Surface the grace-period warning based on *already-persisted* state
|
|
399
|
-
# (computed from last_heartbeat on disk). This works whether the
|
|
400
|
-
# previous run's heartbeat succeeded, failed, or was interrupted —
|
|
401
|
-
# grace_period_exceeded? reads last_heartbeat, not this run's result.
|
|
402
391
|
if brand.grace_period_exceeded?
|
|
403
392
|
say ""
|
|
404
393
|
say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
|
|
@@ -750,6 +739,9 @@ module Clacky
|
|
|
750
739
|
# Inject UI into agent
|
|
751
740
|
agent.instance_variable_set(:@ui, ui_controller)
|
|
752
741
|
|
|
742
|
+
# Inject current session id into UI session bar (parity with WebUI #sib-id)
|
|
743
|
+
ui_controller.update_sessionbar(session_id: agent.session_id)
|
|
744
|
+
|
|
753
745
|
# Set skill loader for command suggestions, filtered by agent profile whitelist
|
|
754
746
|
ui_controller.set_skill_loader(agent.skill_loader, agent.agent_profile)
|
|
755
747
|
|
|
@@ -859,7 +851,7 @@ module Clacky
|
|
|
859
851
|
end
|
|
860
852
|
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
861
853
|
# Update session bar with reset values
|
|
862
|
-
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
854
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost, session_id: agent.session_id)
|
|
863
855
|
# Clear todo area display
|
|
864
856
|
ui_controller.update_todos([])
|
|
865
857
|
next
|
|
@@ -8,6 +8,163 @@ module Clacky
|
|
|
8
8
|
module Channel
|
|
9
9
|
module Adapters
|
|
10
10
|
module Weixin
|
|
11
|
+
# Per-user send queue with buffering, throttling, and retry for Weixin iLink.
|
|
12
|
+
#
|
|
13
|
+
# Design:
|
|
14
|
+
# - Each chat_id has a pending buffer of text fragments.
|
|
15
|
+
# - A background flusher thread periodically checks all buffers.
|
|
16
|
+
# - Flush triggers: char threshold reached, time interval elapsed, or explicit flush.
|
|
17
|
+
# - Actual send calls are spaced by MIN_SEND_INTERVAL to avoid rate-limiting.
|
|
18
|
+
# - ret:-2 (rate-limited) triggers exponential backoff retry.
|
|
19
|
+
class SendQueue
|
|
20
|
+
FLUSH_CHAR_THRESHOLD = 400
|
|
21
|
+
FLUSH_INTERVAL = 0.8
|
|
22
|
+
MIN_SEND_INTERVAL = 1.0
|
|
23
|
+
RETRY_BACKOFFS = [1.0, 2.0, 4.0]
|
|
24
|
+
|
|
25
|
+
Entry = Struct.new(:text, :context_token, :enqueued_at, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
def initialize(api_client, logger: Clacky::Logger)
|
|
28
|
+
@api_client = api_client
|
|
29
|
+
@logger = logger
|
|
30
|
+
@buffers = {}
|
|
31
|
+
@buffer_mutex = Mutex.new
|
|
32
|
+
@last_sent_at = {}
|
|
33
|
+
@last_mutex = Mutex.new
|
|
34
|
+
@running = true
|
|
35
|
+
@flusher = Thread.new { flush_loop }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Enqueue text for a chat_id. Non-blocking.
|
|
39
|
+
def enqueue(chat_id, text, context_token)
|
|
40
|
+
@buffer_mutex.synchronize do
|
|
41
|
+
@buffers[chat_id] ||= []
|
|
42
|
+
@buffers[chat_id] << Entry.new(text: text, context_token: context_token, enqueued_at: Time.now)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Force-flush all pending text for a chat_id. Non-blocking.
|
|
47
|
+
def flush(chat_id)
|
|
48
|
+
entries = @buffer_mutex.synchronize { @buffers.delete(chat_id) || [] }
|
|
49
|
+
send_entries(chat_id, entries) unless entries.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Stop the flusher thread. Waits up to 30s for pending messages to drain.
|
|
53
|
+
def stop
|
|
54
|
+
@running = false
|
|
55
|
+
@flusher.join(30)
|
|
56
|
+
# Force-flush any remaining entries regardless of threshold.
|
|
57
|
+
drain_all_buffers
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private def flush_loop
|
|
61
|
+
while @running
|
|
62
|
+
sleep 0.2
|
|
63
|
+
begin
|
|
64
|
+
drain_buffers
|
|
65
|
+
rescue => e
|
|
66
|
+
@logger.error("[WeixinSendQueue] drain_buffers error: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private def drain_buffers
|
|
72
|
+
now = Time.now
|
|
73
|
+
ready = {}
|
|
74
|
+
|
|
75
|
+
@buffer_mutex.synchronize do
|
|
76
|
+
@buffers.each do |chat_id, entries|
|
|
77
|
+
next if entries.empty?
|
|
78
|
+
total_chars = entries.sum { |e| e.text.chars.length }
|
|
79
|
+
elapsed = now - entries.first.enqueued_at
|
|
80
|
+
if total_chars >= FLUSH_CHAR_THRESHOLD || elapsed >= FLUSH_INTERVAL
|
|
81
|
+
ready[chat_id] = entries
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
ready.each_key { |chat_id| @buffers.delete(chat_id) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ready.each do |chat_id, entries|
|
|
88
|
+
send_entries(chat_id, entries)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Unconditionally drain every buffer. Used on stop to guarantee delivery.
|
|
93
|
+
private def drain_all_buffers
|
|
94
|
+
ready = @buffer_mutex.synchronize do
|
|
95
|
+
snapshot = @buffers.reject { |_, entries| entries.empty? }
|
|
96
|
+
@buffers.clear
|
|
97
|
+
snapshot
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ready.each do |chat_id, entries|
|
|
101
|
+
begin
|
|
102
|
+
send_entries(chat_id, entries)
|
|
103
|
+
rescue => e
|
|
104
|
+
@logger.error("[WeixinSendQueue] final drain error for #{chat_id}: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private def send_entries(chat_id, entries)
|
|
110
|
+
return if entries.empty?
|
|
111
|
+
|
|
112
|
+
combined = entries.map(&:text).join("\n")
|
|
113
|
+
ctoken = entries.last.context_token
|
|
114
|
+
|
|
115
|
+
# Split into ≤2000 char chunks
|
|
116
|
+
chunks = split_message(combined)
|
|
117
|
+
chunks.each do |chunk|
|
|
118
|
+
throttle
|
|
119
|
+
send_with_retry(chat_id, chunk, ctoken)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private def throttle
|
|
124
|
+
@last_mutex.synchronize do
|
|
125
|
+
last = @last_sent_at[:global] || Time.at(0)
|
|
126
|
+
wait = MIN_SEND_INTERVAL - (Time.now - last)
|
|
127
|
+
sleep(wait) if wait > 0
|
|
128
|
+
@last_sent_at[:global] = Time.now
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private def send_with_retry(chat_id, text, context_token)
|
|
133
|
+
RETRY_BACKOFFS.each_with_index do |delay, idx|
|
|
134
|
+
begin
|
|
135
|
+
@api_client.send_text(to_user_id: chat_id, text: text, context_token: context_token)
|
|
136
|
+
return
|
|
137
|
+
rescue ApiClient::ApiError => e
|
|
138
|
+
if e.code == -2 && idx < RETRY_BACKOFFS.length - 1
|
|
139
|
+
@logger.warn("[WeixinSendQueue] ret=-2 for #{chat_id}, retry in #{delay}s (#{idx + 1}/#{RETRY_BACKOFFS.length})")
|
|
140
|
+
sleep delay
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
raise
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue => e
|
|
147
|
+
@logger.error("[WeixinSendQueue] send_text failed for #{chat_id}: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Split text into ≤2000 Unicode character chunks.
|
|
151
|
+
private def split_message(text, limit: 2000)
|
|
152
|
+
return [text] if text.chars.length <= limit
|
|
153
|
+
chunks = []
|
|
154
|
+
while text.chars.length > limit
|
|
155
|
+
window = text.chars.first(limit).join
|
|
156
|
+
cut = window.rindex("\n\n")
|
|
157
|
+
cut = window.rindex("\n") if cut.nil?
|
|
158
|
+
cut = window.rindex(" ") if cut.nil?
|
|
159
|
+
cut = limit if cut.nil? || cut.zero?
|
|
160
|
+
chunks << text.chars.first(cut).join.rstrip
|
|
161
|
+
text = text.chars.drop(cut).join.lstrip
|
|
162
|
+
end
|
|
163
|
+
chunks << text unless text.empty?
|
|
164
|
+
chunks
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
11
168
|
# Weixin (WeChat iLink) adapter.
|
|
12
169
|
#
|
|
13
170
|
# Protocol: HTTP long-poll via ilinkai.weixin.qq.com
|
|
@@ -76,6 +233,7 @@ module Clacky
|
|
|
76
233
|
@context_tokens = {}
|
|
77
234
|
@ctx_mutex = Mutex.new
|
|
78
235
|
@api_client = ApiClient.new(base_url: @base_url, token: @token)
|
|
236
|
+
@send_queue = SendQueue.new(@api_client)
|
|
79
237
|
# Typing keepalive: user_id → { ticket:, thread:, cached_at: }
|
|
80
238
|
@typing_tickets = {}
|
|
81
239
|
@typing_mutex = Mutex.new
|
|
@@ -129,10 +287,12 @@ module Clacky
|
|
|
129
287
|
|
|
130
288
|
def stop
|
|
131
289
|
@running = false
|
|
290
|
+
@send_queue.stop
|
|
132
291
|
end
|
|
133
292
|
|
|
134
293
|
# Send a plain text reply to a user.
|
|
135
294
|
# The context_token from the inbound message is required by the Weixin protocol.
|
|
295
|
+
# Text is enqueued and sent in batches by the background flusher to avoid rate-limiting.
|
|
136
296
|
def send_text(chat_id, text, reply_to: nil)
|
|
137
297
|
ctoken = lookup_context_token(chat_id)
|
|
138
298
|
unless ctoken
|
|
@@ -141,14 +301,15 @@ module Clacky
|
|
|
141
301
|
end
|
|
142
302
|
|
|
143
303
|
plain = markdown_to_plain(text)
|
|
144
|
-
|
|
145
|
-
@api_client.send_text(to_user_id: chat_id, text: chunk, context_token: ctoken)
|
|
146
|
-
end
|
|
304
|
+
return { message_id: nil } if plain.empty?
|
|
147
305
|
|
|
306
|
+
@send_queue.enqueue(chat_id, plain, ctoken)
|
|
148
307
|
{ message_id: nil }
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Force-flush pending text for a chat_id. Called before sending files or on task completion.
|
|
311
|
+
def flush_pending(chat_id)
|
|
312
|
+
@send_queue.flush(chat_id)
|
|
152
313
|
end
|
|
153
314
|
|
|
154
315
|
# Send a file to a user.
|
|
@@ -161,6 +322,8 @@ module Clacky
|
|
|
161
322
|
return { message_id: nil }
|
|
162
323
|
end
|
|
163
324
|
|
|
325
|
+
@send_queue.flush(chat_id)
|
|
326
|
+
|
|
164
327
|
@api_client.send_file(
|
|
165
328
|
to_user_id: chat_id,
|
|
166
329
|
file_path: file_path,
|
|
@@ -62,6 +62,7 @@ module Clacky
|
|
|
62
62
|
# raw markdown links would just be noise in the chat.
|
|
63
63
|
text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
|
|
64
64
|
send_text(text) unless text.empty?
|
|
65
|
+
flush_adapter_pending
|
|
65
66
|
files.each do |f|
|
|
66
67
|
Clacky::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
|
|
67
68
|
send_file(f[:path], f[:name])
|
|
@@ -120,6 +121,7 @@ module Clacky
|
|
|
120
121
|
end
|
|
121
122
|
parts << "#{duration.round(1)}s" if duration
|
|
122
123
|
send_text(parts.join(" · "))
|
|
124
|
+
flush_adapter_pending
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
def append_output(content)
|
|
@@ -218,6 +220,10 @@ module Clacky
|
|
|
218
220
|
send_text(@buffer.join("\n"))
|
|
219
221
|
@buffer.clear
|
|
220
222
|
end
|
|
223
|
+
|
|
224
|
+
def flush_adapter_pending
|
|
225
|
+
@adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
|
|
226
|
+
end
|
|
221
227
|
end
|
|
222
228
|
end
|
|
223
229
|
end
|
|
@@ -3156,6 +3156,7 @@ module Clacky
|
|
|
3156
3156
|
# Export a session bundle as a .zip download containing:
|
|
3157
3157
|
# - session.json (always)
|
|
3158
3158
|
# - chunk-*.md (0..N archived conversation chunks)
|
|
3159
|
+
# - logs/clacky-YYYY-MM-DD.log (today's logger file, if present)
|
|
3159
3160
|
# Useful for debugging — user clicks "download" in the WebUI status bar
|
|
3160
3161
|
# and we can ask them to attach the zip to a bug report.
|
|
3161
3162
|
def api_export_session(session_id, res)
|
|
@@ -3177,6 +3178,12 @@ module Clacky
|
|
|
3177
3178
|
zos.put_next_entry(File.basename(chunk_path))
|
|
3178
3179
|
zos.write(File.binread(chunk_path))
|
|
3179
3180
|
end
|
|
3181
|
+
|
|
3182
|
+
log_path = Clacky::Logger.current_log_file
|
|
3183
|
+
if log_path && File.exist?(log_path)
|
|
3184
|
+
zos.put_next_entry("logs/#{File.basename(log_path)}")
|
|
3185
|
+
zos.write(File.binread(log_path))
|
|
3186
|
+
end
|
|
3180
3187
|
end
|
|
3181
3188
|
buffer.rewind
|
|
3182
3189
|
data = buffer.read
|
|
@@ -494,6 +494,12 @@ module Clacky
|
|
|
494
494
|
else
|
|
495
495
|
cleanup_session(session)
|
|
496
496
|
end
|
|
497
|
+
if xcode_tools_missing?(cleaned)
|
|
498
|
+
cleaned = "Xcode Command Line Tools are not installed.\n" \
|
|
499
|
+
"Run: bash ~/.clacky/scripts/install_system_deps.sh\n" \
|
|
500
|
+
"Then retry the original command."
|
|
501
|
+
exit_code = 1
|
|
502
|
+
end
|
|
497
503
|
{
|
|
498
504
|
output: cleaned,
|
|
499
505
|
exit_code: exit_code,
|
|
@@ -521,6 +527,11 @@ module Clacky
|
|
|
521
527
|
end
|
|
522
528
|
end
|
|
523
529
|
|
|
530
|
+
private def xcode_tools_missing?(output)
|
|
531
|
+
return false if output.nil? || output.empty?
|
|
532
|
+
output.include?("xcode-select") && output.include?("No developer tools were found")
|
|
533
|
+
end
|
|
534
|
+
|
|
524
535
|
private def session_healthy?(session)
|
|
525
536
|
return false unless session
|
|
526
537
|
return false if %w[exited killed].include?(session.status.to_s)
|
|
@@ -57,6 +57,7 @@ module Clacky
|
|
|
57
57
|
|
|
58
58
|
# Session bar info
|
|
59
59
|
@sessionbar_info = {
|
|
60
|
+
session_id: nil, # Full session id; rendered as first 8 chars (parity with WebUI)
|
|
60
61
|
working_dir: nil,
|
|
61
62
|
mode: nil,
|
|
62
63
|
model: nil,
|
|
@@ -138,6 +139,7 @@ module Clacky
|
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
# Update session bar info
|
|
142
|
+
# @param session_id [String] Full session id (rendered as first 8 chars)
|
|
141
143
|
# @param working_dir [String] Working directory
|
|
142
144
|
# @param mode [String] Permission mode
|
|
143
145
|
# @param model [String] AI model name
|
|
@@ -145,7 +147,8 @@ module Clacky
|
|
|
145
147
|
# @param cost [Float] Total cost
|
|
146
148
|
# @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
|
|
147
149
|
# @param status [String] Workspace status ('idle' or 'working')
|
|
148
|
-
def update_sessionbar(working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
150
|
+
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
151
|
+
@sessionbar_info[:session_id] = session_id if session_id
|
|
149
152
|
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
150
153
|
@sessionbar_info[:mode] = mode if mode
|
|
151
154
|
@sessionbar_info[:model] = model if model
|
|
@@ -1136,6 +1139,12 @@ module Clacky
|
|
|
1136
1139
|
parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
|
|
1137
1140
|
end
|
|
1138
1141
|
|
|
1142
|
+
# Session id — first 8 chars (parity with WebUI #sib-id)
|
|
1143
|
+
if @sessionbar_info[:session_id]
|
|
1144
|
+
sid_short = @sessionbar_info[:session_id].to_s[0, 8]
|
|
1145
|
+
parts << theme.format_text(sid_short, :statusbar_secondary) unless sid_short.empty?
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1139
1148
|
# Working directory (shortened if too long)
|
|
1140
1149
|
if @sessionbar_info[:working_dir]
|
|
1141
1150
|
dir_display = shorten_path(@sessionbar_info[:working_dir])
|
|
@@ -14,9 +14,13 @@ module Clacky
|
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
16
|
@todos = []
|
|
17
|
+
@pending_todos = []
|
|
18
|
+
@completed_count = 0
|
|
19
|
+
@total_count = 0
|
|
17
20
|
@pastel = Pastel.new
|
|
18
21
|
@width = TTY::Screen.width
|
|
19
22
|
@height = 0 # Dynamic height based on todos
|
|
23
|
+
@hidden = false
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# Update todos list
|
|
@@ -27,8 +31,24 @@ module Clacky
|
|
|
27
31
|
@completed_count = @todos.count { |t| t[:status] == "completed" }
|
|
28
32
|
@total_count = @todos.size
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
recalc_height
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Hide the area without discarding todos data; show again to restore.
|
|
38
|
+
def hide
|
|
39
|
+
return if @hidden
|
|
40
|
+
@hidden = true
|
|
41
|
+
@height = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
return unless @hidden
|
|
46
|
+
@hidden = false
|
|
47
|
+
recalc_height
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private def recalc_height
|
|
51
|
+
if @hidden || @pending_todos.empty?
|
|
32
52
|
@height = 0
|
|
33
53
|
else
|
|
34
54
|
@height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
|
|
@@ -126,6 +126,10 @@ module Clacky
|
|
|
126
126
|
|
|
127
127
|
old_lines = entry.lines.dup
|
|
128
128
|
new_lines = wrap_content_to_lines(content)
|
|
129
|
+
if old_lines == new_lines
|
|
130
|
+
screen.flush
|
|
131
|
+
return
|
|
132
|
+
end
|
|
129
133
|
@buffer.replace(id, new_lines)
|
|
130
134
|
|
|
131
135
|
unless @fullscreen_mode
|
|
@@ -288,24 +292,28 @@ module Clacky
|
|
|
288
292
|
end
|
|
289
293
|
end
|
|
290
294
|
|
|
291
|
-
# Clear
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Paint the new content
|
|
295
|
+
# Clear only rows whose content actually changed, then repaint
|
|
296
|
+
# those. Lines that are byte-identical to the previous frame stay
|
|
297
|
+
# untouched — avoiding the clear-then-redraw flicker that an
|
|
298
|
+
# always-on ticker produces 2-10x per second on slower terminals.
|
|
298
299
|
cur = start_row
|
|
299
|
-
new_lines.
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
new_lines.each_with_index do |line, i|
|
|
301
|
+
if i >= old_n || old_lines[i] != line
|
|
302
|
+
screen.move_cursor(cur, 0)
|
|
303
|
+
screen.clear_line
|
|
304
|
+
print line
|
|
305
|
+
end
|
|
302
306
|
cur += 1
|
|
303
307
|
end
|
|
308
|
+
# If content shrank, blank out the rows the old frame occupied
|
|
309
|
+
# below the new tail.
|
|
310
|
+
if new_n < old_n
|
|
311
|
+
(cur...(start_row + old_n)).each do |row|
|
|
312
|
+
screen.move_cursor(row, 0)
|
|
313
|
+
screen.clear_line
|
|
314
|
+
end
|
|
315
|
+
end
|
|
304
316
|
@output_row = start_row + new_n
|
|
305
|
-
|
|
306
|
-
# If content shrank, extra rows below may still hold the old content
|
|
307
|
-
# if they were outside the cleared range — but since we cleared the
|
|
308
|
-
# full old span above, nothing extra is needed here.
|
|
309
317
|
end
|
|
310
318
|
|
|
311
319
|
# Clear the last N rows of the output area (used by remove_entry on tail).
|
|
@@ -502,6 +510,54 @@ module Clacky
|
|
|
502
510
|
end
|
|
503
511
|
end
|
|
504
512
|
|
|
513
|
+
# Hide todo area while preserving its data; pair with show_todos.
|
|
514
|
+
def hide_todos
|
|
515
|
+
return unless @todo_area
|
|
516
|
+
|
|
517
|
+
@render_mutex.synchronize do
|
|
518
|
+
old_height = @todo_area.height
|
|
519
|
+
old_gap_row = @gap_row
|
|
520
|
+
|
|
521
|
+
@todo_area.hide
|
|
522
|
+
new_height = @todo_area.height
|
|
523
|
+
|
|
524
|
+
if old_height != new_height
|
|
525
|
+
calculate_layout
|
|
526
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
527
|
+
screen.move_cursor(row, 0)
|
|
528
|
+
screen.clear_line
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
render_fixed_areas
|
|
533
|
+
screen.flush
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Show todo area again after a previous hide_todos.
|
|
538
|
+
def show_todos
|
|
539
|
+
return unless @todo_area
|
|
540
|
+
|
|
541
|
+
@render_mutex.synchronize do
|
|
542
|
+
old_height = @todo_area.height
|
|
543
|
+
old_gap_row = @gap_row
|
|
544
|
+
|
|
545
|
+
@todo_area.show
|
|
546
|
+
new_height = @todo_area.height
|
|
547
|
+
|
|
548
|
+
if old_height != new_height
|
|
549
|
+
calculate_layout
|
|
550
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
551
|
+
screen.move_cursor(row, 0)
|
|
552
|
+
screen.clear_line
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
render_fixed_areas
|
|
557
|
+
screen.flush
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
505
561
|
|
|
506
562
|
|
|
507
563
|
# -----------------------------------------------------------------------
|
|
@@ -225,6 +225,15 @@ module Clacky
|
|
|
225
225
|
render_now
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
# Like __reattach_entry! but skips the render_now hop. Used by the
|
|
229
|
+
# owner when it has just painted a frame into the new entry itself
|
|
230
|
+
# (e.g. while rotating the handle to remain at the buffer tail) and
|
|
231
|
+
# is still inside its own synchronization — calling render_now there
|
|
232
|
+
# would re-enter the owner's mutex.
|
|
233
|
+
def __rebind_entry!(new_entry_id)
|
|
234
|
+
@monitor.synchronize { @entry_id = new_entry_id }
|
|
235
|
+
end
|
|
236
|
+
|
|
228
237
|
# Test hook: force a synchronous render regardless of tick cadence.
|
|
229
238
|
def __force_render!
|
|
230
239
|
render_now
|
|
@@ -49,6 +49,7 @@ module Clacky
|
|
|
49
49
|
@time_machine_callback = nil
|
|
50
50
|
@tasks_count = 0
|
|
51
51
|
@total_cost = 0.0
|
|
52
|
+
@session_id = nil
|
|
52
53
|
@last_diff_lines = nil
|
|
53
54
|
|
|
54
55
|
# ── Progress subsystem (v2: owned handles, stacked) ──────────────
|
|
@@ -73,6 +74,7 @@ module Clacky
|
|
|
73
74
|
|
|
74
75
|
# Set session bar data before initializing screen
|
|
75
76
|
@input_area.update_sessionbar(
|
|
77
|
+
session_id: @session_id,
|
|
76
78
|
working_dir: @config[:working_dir],
|
|
77
79
|
mode: @config[:mode],
|
|
78
80
|
model: @config[:model],
|
|
@@ -109,10 +111,13 @@ module Clacky
|
|
|
109
111
|
# @param cost_source [Symbol, nil] :api / :price / :default (optional)
|
|
110
112
|
# @param status [String] Workspace status ('idle' or 'working') (optional)
|
|
111
113
|
# @param latency [Hash, nil] Latency metrics; accepted but not displayed in the TUI.
|
|
112
|
-
|
|
114
|
+
# @param session_id [String, nil] Full session id; rendered as first 8 chars (parity with WebUI).
|
|
115
|
+
def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
|
|
113
116
|
@tasks_count = tasks if tasks
|
|
114
117
|
@total_cost = cost if cost
|
|
118
|
+
@session_id = session_id if session_id
|
|
115
119
|
@input_area.update_sessionbar(
|
|
120
|
+
session_id: @session_id,
|
|
116
121
|
working_dir: @config[:working_dir],
|
|
117
122
|
mode: @config[:mode],
|
|
118
123
|
model: @config[:model],
|
|
@@ -193,9 +198,35 @@ module Clacky
|
|
|
193
198
|
@input_area.set_agent(agent, agent_profile)
|
|
194
199
|
end
|
|
195
200
|
|
|
196
|
-
# Append output to the output area
|
|
197
|
-
#
|
|
201
|
+
# Append output to the output area.
|
|
202
|
+
#
|
|
203
|
+
# If a progress indicator is currently active (somewhere in the
|
|
204
|
+
# buffer), rotate it to the tail after the append: business content
|
|
205
|
+
# ends up above, the spinner stays at the bottom. Without this,
|
|
206
|
+
# every subsequent ticker tick on a non-tail progress entry would
|
|
207
|
+
# trigger a full output repaint (visible flicker) and the visual
|
|
208
|
+
# order would have business messages appearing below the spinner.
|
|
198
209
|
def append_output(content)
|
|
210
|
+
@progress_mutex.synchronize do
|
|
211
|
+
top = @progress_stack.last
|
|
212
|
+
if top && top.entry_id
|
|
213
|
+
@layout.remove_entry(top.entry_id)
|
|
214
|
+
top.__detach_entry!
|
|
215
|
+
new_id = @layout.append_output(content)
|
|
216
|
+
progress_id = @layout.append_output(render_for(top))
|
|
217
|
+
top.__rebind_entry!(progress_id)
|
|
218
|
+
new_id
|
|
219
|
+
else
|
|
220
|
+
@layout.append_output(content)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Internal append that bypasses the progress-rotation logic and the
|
|
226
|
+
# @progress_mutex. Used by register_progress / unregister_progress,
|
|
227
|
+
# which already hold the mutex and are themselves placing a fresh
|
|
228
|
+
# progress entry at the tail.
|
|
229
|
+
private def append_output_unlocked(content)
|
|
199
230
|
@layout.append_output(content)
|
|
200
231
|
end
|
|
201
232
|
|
|
@@ -481,6 +512,8 @@ module Clacky
|
|
|
481
512
|
|
|
482
513
|
# Clear user tip when agent stops working
|
|
483
514
|
@input_area.clear_user_tip
|
|
515
|
+
# Hide todo area while idle (data preserved, restored on next work)
|
|
516
|
+
@layout.hide_todos
|
|
484
517
|
@layout.render_input
|
|
485
518
|
|
|
486
519
|
# Don't show completion message if awaiting user feedback
|
|
@@ -597,7 +630,7 @@ module Clacky
|
|
|
597
630
|
end
|
|
598
631
|
|
|
599
632
|
@progress_stack.push(handle)
|
|
600
|
-
entry_id =
|
|
633
|
+
entry_id = append_output_unlocked(render_for(handle))
|
|
601
634
|
recompute_sessionbar_status
|
|
602
635
|
entry_id
|
|
603
636
|
end
|
|
@@ -623,7 +656,7 @@ module Clacky
|
|
|
623
656
|
# Restore the new top, if any: allocate a fresh entry and let it
|
|
624
657
|
# resume rendering from where it left off.
|
|
625
658
|
if (restored = @progress_stack.last)
|
|
626
|
-
new_id =
|
|
659
|
+
new_id = append_output_unlocked(render_for(restored))
|
|
627
660
|
restored.__reattach_entry!(new_id)
|
|
628
661
|
end
|
|
629
662
|
|
|
@@ -826,6 +859,8 @@ module Clacky
|
|
|
826
859
|
@last_sessionbar_status = 'idle'
|
|
827
860
|
# Clear user tip when agent stops working
|
|
828
861
|
@input_area.clear_user_tip
|
|
862
|
+
# Hide todo area while idle (data preserved, restored on next work)
|
|
863
|
+
@layout.hide_todos
|
|
829
864
|
@layout.render_input
|
|
830
865
|
end
|
|
831
866
|
|
|
@@ -848,6 +883,8 @@ module Clacky
|
|
|
848
883
|
# Set workspace status to working (called when agent starts working)
|
|
849
884
|
def set_working_status
|
|
850
885
|
update_sessionbar(status: 'working')
|
|
886
|
+
# Restore todo area if it was hidden during idle
|
|
887
|
+
@layout.show_todos
|
|
851
888
|
# Show a random user tip with 40% probability when agent starts working
|
|
852
889
|
@input_area.show_user_tip(probability: 0.4)
|
|
853
890
|
@layout.render_input
|
|
@@ -1281,6 +1318,7 @@ module Clacky
|
|
|
1281
1318
|
|
|
1282
1319
|
# Update session bar data (will be rendered by request_confirmation's render_all)
|
|
1283
1320
|
@input_area.update_sessionbar(
|
|
1321
|
+
session_id: @session_id,
|
|
1284
1322
|
working_dir: @config[:working_dir],
|
|
1285
1323
|
mode: @config[:mode],
|
|
1286
1324
|
model: @config[:model],
|
data/lib/clacky/utils/logger.rb
CHANGED
|
@@ -35,6 +35,13 @@ module Clacky
|
|
|
35
35
|
@console ||= false
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Path of the log file currently being written to (today's file).
|
|
39
|
+
# File may not exist yet if no log has been emitted today — callers
|
|
40
|
+
# should check File.exist? before reading.
|
|
41
|
+
def current_log_file
|
|
42
|
+
log_file_path(Time.now)
|
|
43
|
+
end
|
|
44
|
+
|
|
38
45
|
# Log at DEBUG level.
|
|
39
46
|
def debug(message, **context)
|
|
40
47
|
write_log(:debug, message, context)
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -66,11 +66,15 @@ const I18n = (() => {
|
|
|
66
66
|
"sessions.deleteTitle": "Delete session",
|
|
67
67
|
"sessions.createError": "Error: ",
|
|
68
68
|
"sessions.dirNotEmpty": "Directory already exists and is not empty.",
|
|
69
|
-
"sessions.export.tooltip": "Download session files (session.json + chunks) for debugging",
|
|
69
|
+
"sessions.export.tooltip": "Download session files (session.json + chunks + today's log) for debugging",
|
|
70
70
|
"sessions.export.failed": "Failed to download session",
|
|
71
71
|
"sessions.actions.tooltip": "Click for session actions",
|
|
72
72
|
"sessions.actions.download": "Download session files",
|
|
73
73
|
"sessions.actions.downloadHint": "for debugging",
|
|
74
|
+
"sib.dir.tooltip": "Click to change directory",
|
|
75
|
+
"sib.dir.changePrompt": "Change working directory:",
|
|
76
|
+
"sib.model.tooltip": "Click to switch model",
|
|
77
|
+
"sib.signal.tooltip": "Recent LLM latency",
|
|
74
78
|
"sessions.thinking": "Thinking…",
|
|
75
79
|
"sessions.default_name": "Session {{n}}",
|
|
76
80
|
"sessions.badge.cron": "Auto",
|
|
@@ -449,8 +453,8 @@ const I18n = (() => {
|
|
|
449
453
|
"settings.brand.label.qrHint": "Scan with your phone camera",
|
|
450
454
|
"settings.brand.btn.change": "Change Serial Number",
|
|
451
455
|
"settings.brand.btn.unbind": "Unbind License",
|
|
452
|
-
"settings.brand.confirmRebind": "
|
|
453
|
-
"settings.brand.confirmUnbind": "
|
|
456
|
+
"settings.brand.confirmRebind": "Enter a new serial number to rebind. If it belongs to the same brand, your installed brand skills are preserved; switching to a different brand will remove them. Continue?",
|
|
457
|
+
"settings.brand.confirmUnbind": "Unbind this license? All installed brand skills will be removed, and this device will no longer have access to branded features.",
|
|
454
458
|
"settings.brand.unbindSuccess": "License unbound successfully.",
|
|
455
459
|
"settings.brand.unbindFailed": "Failed to unbind license. Please try again.",
|
|
456
460
|
"settings.brand.badge.active": "Active",
|
|
@@ -585,11 +589,15 @@ const I18n = (() => {
|
|
|
585
589
|
"sessions.deleteTitle": "删除对话",
|
|
586
590
|
"sessions.createError": "错误:",
|
|
587
591
|
"sessions.dirNotEmpty": "该目录已存在且不为空,请换一个目录名。",
|
|
588
|
-
"sessions.export.tooltip": "下载会话文件(session.json +
|
|
592
|
+
"sessions.export.tooltip": "下载会话文件(session.json + 归档片段 + 当天日志),用于调试",
|
|
589
593
|
"sessions.export.failed": "下载会话文件失败",
|
|
590
594
|
"sessions.actions.tooltip": "点击查看会话操作",
|
|
591
595
|
"sessions.actions.download": "下载会话文件",
|
|
592
596
|
"sessions.actions.downloadHint": "用于调试",
|
|
597
|
+
"sib.dir.tooltip": "点击切换工作目录",
|
|
598
|
+
"sib.dir.changePrompt": "切换工作目录:",
|
|
599
|
+
"sib.model.tooltip": "点击切换模型",
|
|
600
|
+
"sib.signal.tooltip": "最近一次 LLM 响应延迟",
|
|
593
601
|
"sessions.thinking": "思考中…",
|
|
594
602
|
"sessions.default_name": "对话 {{n}}",
|
|
595
603
|
"sessions.badge.cron": "定时",
|
|
@@ -612,6 +620,7 @@ const I18n = (() => {
|
|
|
612
620
|
"sessions.search.typeCoding": "Coding",
|
|
613
621
|
"sessions.search.typeSetup": "配置",
|
|
614
622
|
"sessions.search.datePlaceholder": "日期",
|
|
623
|
+
"modal.yes": "确定",
|
|
615
624
|
"modal.no": "取消",
|
|
616
625
|
"modal.ok": "确定",
|
|
617
626
|
"modal.cancel": "取消",
|
|
@@ -964,8 +973,8 @@ const I18n = (() => {
|
|
|
964
973
|
"settings.brand.label.qrHint": "使用手机扫描二维码",
|
|
965
974
|
"settings.brand.btn.change": "更换序列号",
|
|
966
975
|
"settings.brand.btn.unbind": "解绑授权",
|
|
967
|
-
"settings.brand.confirmRebind": "
|
|
968
|
-
"settings.brand.confirmUnbind": "
|
|
976
|
+
"settings.brand.confirmRebind": "请输入新序列号以重新绑定。若属于同一品牌,已安装的品牌技能将保留;切换为其他品牌时才会清理。是否继续?",
|
|
977
|
+
"settings.brand.confirmUnbind": "确定要解绑此授权吗?所有已安装的品牌技能将被删除,本设备将无法再访问品牌功能。",
|
|
969
978
|
"settings.brand.unbindSuccess": "授权解绑成功。",
|
|
970
979
|
"settings.brand.unbindFailed": "解绑授权失败,请重试。",
|
|
971
980
|
"settings.brand.badge.active": "已激活",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -300,10 +300,10 @@
|
|
|
300
300
|
<div id="sib-actions-dropdown" class="sib-actions-dropdown" style="display:none" role="menu"></div>
|
|
301
301
|
</span>
|
|
302
302
|
<span class="sib-sep sib-sep-after-id">│</span>
|
|
303
|
-
<span id="sib-dir" title="Click to change directory"></span>
|
|
303
|
+
<span id="sib-dir" data-i18n-title="sib.dir.tooltip" title="Click to change directory"></span>
|
|
304
304
|
<span class="sib-sep sib-sep-after-dir">│</span>
|
|
305
305
|
<span id="sib-model-wrap">
|
|
306
|
-
<span id="sib-model" class="sib-model-clickable" title="Click to switch model"></span>
|
|
306
|
+
<span id="sib-model" class="sib-model-clickable" data-i18n-title="sib.model.tooltip" title="Click to switch model"></span>
|
|
307
307
|
<div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
|
|
308
308
|
</span>
|
|
309
309
|
<span class="sib-sep sib-sep-after-model">│</span>
|
|
@@ -311,7 +311,7 @@
|
|
|
311
311
|
call completes (see updateInfoBar / Sessions.renderSignalBars). Click
|
|
312
312
|
opens a mini benchmark panel (see Step 3/4 — not yet implemented). -->
|
|
313
313
|
<span id="sib-signal-wrap" style="display:none">
|
|
314
|
-
<span id="sib-signal" class="sib-signal-clickable" title="Recent LLM latency">
|
|
314
|
+
<span id="sib-signal" class="sib-signal-clickable" data-i18n-title="sib.signal.tooltip" title="Recent LLM latency">
|
|
315
315
|
<span class="sig-bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
316
316
|
<span class="sig-text"></span>
|
|
317
317
|
</span>
|
|
@@ -945,8 +945,8 @@
|
|
|
945
945
|
</div>
|
|
946
946
|
</div>
|
|
947
947
|
<div class="modal-footer">
|
|
948
|
-
<button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
|
|
949
948
|
<button id="new-session-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
|
|
949
|
+
<button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
|
|
950
950
|
</div>
|
|
951
951
|
</div>
|
|
952
952
|
</div>
|
|
@@ -968,8 +968,8 @@
|
|
|
968
968
|
<div id="prompt-modal-message" style="font-size:14px;line-height:1.6;margin-bottom:12px"></div>
|
|
969
969
|
<input type="text" id="prompt-modal-input" class="prompt-modal-input" autocomplete="off" spellcheck="false">
|
|
970
970
|
<div class="modal-actions">
|
|
971
|
-
<button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
|
|
972
971
|
<button id="prompt-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
|
|
972
|
+
<button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
|
|
973
973
|
</div>
|
|
974
974
|
</div>
|
|
975
975
|
</div>
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -2065,6 +2065,7 @@ const Sessions = (() => {
|
|
|
2065
2065
|
|
|
2066
2066
|
/** Update the session info bar below the chat header with current session metadata. */
|
|
2067
2067
|
updateInfoBar(s) {
|
|
2068
|
+
this._lastSession = s;
|
|
2068
2069
|
if (!s) {
|
|
2069
2070
|
// Hide all spans when no session
|
|
2070
2071
|
["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
|
|
@@ -2103,8 +2104,8 @@ const Sessions = (() => {
|
|
|
2103
2104
|
const sibDir = $("sib-dir");
|
|
2104
2105
|
if (sibDir && s.working_dir) {
|
|
2105
2106
|
sibDir.textContent = s.working_dir;
|
|
2106
|
-
sibDir.title = s.working_dir
|
|
2107
|
-
|
|
2107
|
+
sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
|
|
2108
|
+
sibDir.dataset.workingDir = s.working_dir;
|
|
2108
2109
|
sibDir.dataset.sessionId = s.id;
|
|
2109
2110
|
}
|
|
2110
2111
|
|
|
@@ -2138,7 +2139,7 @@ const Sessions = (() => {
|
|
|
2138
2139
|
|
|
2139
2140
|
// Tasks
|
|
2140
2141
|
const sibTasks = $("sib-tasks");
|
|
2141
|
-
if (sibTasks) sibTasks.textContent =
|
|
2142
|
+
if (sibTasks) sibTasks.textContent = I18n.t("sessions.metaTasks", { n: s.total_tasks || 0 });
|
|
2142
2143
|
|
|
2143
2144
|
// Cost — show N/A when pricing is unknown (estimated)
|
|
2144
2145
|
const sibCost = $("sib-cost");
|
|
@@ -3208,9 +3209,9 @@ const Sessions = (() => {
|
|
|
3208
3209
|
if (dirEl) {
|
|
3209
3210
|
e.stopPropagation();
|
|
3210
3211
|
const sessionId = dirEl.dataset.sessionId;
|
|
3211
|
-
const currentDir = dirEl.
|
|
3212
|
-
|
|
3213
|
-
const newDir = await Modal.prompt("
|
|
3212
|
+
const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
|
|
3213
|
+
|
|
3214
|
+
const newDir = await Modal.prompt(I18n.t("sib.dir.changePrompt"), currentDir);
|
|
3214
3215
|
if (newDir && newDir !== currentDir) {
|
|
3215
3216
|
_changeWorkingDirectory(sessionId, newDir);
|
|
3216
3217
|
}
|
|
@@ -3280,7 +3281,10 @@ const Sessions = (() => {
|
|
|
3280
3281
|
}
|
|
3281
3282
|
|
|
3282
3283
|
function _populateSessionActionsDropdown(dd, sessionId) {
|
|
3283
|
-
const t = (key, fallback) =>
|
|
3284
|
+
const t = (key, fallback) => {
|
|
3285
|
+
const s = I18n.t(key);
|
|
3286
|
+
return (s && s !== key) ? s : fallback;
|
|
3287
|
+
};
|
|
3284
3288
|
dd.innerHTML = "";
|
|
3285
3289
|
|
|
3286
3290
|
// Download item
|
|
@@ -3320,7 +3324,7 @@ const Sessions = (() => {
|
|
|
3320
3324
|
if (!res.ok) {
|
|
3321
3325
|
let msg = `HTTP ${res.status}`;
|
|
3322
3326
|
try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
|
|
3323
|
-
alert(
|
|
3327
|
+
alert(I18n.t("sessions.export.failed") + ": " + msg);
|
|
3324
3328
|
return;
|
|
3325
3329
|
}
|
|
3326
3330
|
const blob = await res.blob();
|
|
@@ -3342,7 +3346,7 @@ const Sessions = (() => {
|
|
|
3342
3346
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3343
3347
|
} catch (err) {
|
|
3344
3348
|
console.error("Session export failed:", err);
|
|
3345
|
-
alert(
|
|
3349
|
+
alert(I18n.t("sessions.export.failed") + ": " + err.message);
|
|
3346
3350
|
} finally {
|
|
3347
3351
|
if (btnEl) {
|
|
3348
3352
|
try { btnEl.disabled = wasDisabled; } catch (_) {}
|
|
@@ -3370,7 +3374,8 @@ const Sessions = (() => {
|
|
|
3370
3374
|
const sibDir = $("sib-dir");
|
|
3371
3375
|
if (sibDir) {
|
|
3372
3376
|
sibDir.textContent = newDir;
|
|
3373
|
-
sibDir.title = newDir
|
|
3377
|
+
sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
|
|
3378
|
+
sibDir.dataset.workingDir = newDir;
|
|
3374
3379
|
}
|
|
3375
3380
|
|
|
3376
3381
|
console.log(`Changed session ${sessionId} directory to ${newDir}`);
|
|
@@ -3381,3 +3386,7 @@ const Sessions = (() => {
|
|
|
3381
3386
|
}
|
|
3382
3387
|
|
|
3383
3388
|
})();
|
|
3389
|
+
|
|
3390
|
+
document.addEventListener("langchange", () => {
|
|
3391
|
+
if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
|
|
3392
|
+
});
|
|
@@ -49,6 +49,7 @@ install_via_gem() {
|
|
|
49
49
|
configure_gem_source
|
|
50
50
|
setup_gem_home
|
|
51
51
|
|
|
52
|
+
local target source_args=()
|
|
52
53
|
if [ "$USE_CN_MIRRORS" = true ]; then
|
|
53
54
|
print_info "Fetching latest version from OSS..."
|
|
54
55
|
local cn_version; cn_version=$(curl -fsSL "$CN_GEM_LATEST_URL" | tr -d '[:space:]')
|
|
@@ -57,17 +58,26 @@ install_via_gem() {
|
|
|
57
58
|
local gem_file="/tmp/openclacky-${cn_version}.gem"
|
|
58
59
|
print_info "Downloading openclacky-${cn_version}.gem..."
|
|
59
60
|
curl -fsSL "$gem_url" -o "$gem_file"
|
|
60
|
-
|
|
61
|
+
target="$gem_file"
|
|
62
|
+
source_args=(--source "$CN_RUBYGEMS_URL")
|
|
61
63
|
else
|
|
62
|
-
|
|
64
|
+
target="openclacky"
|
|
63
65
|
fi
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
# macOS system Ruby 2.6 has a buggy gem resolver that fails on rouge 4.x.
|
|
68
|
+
# Pre-install a 2.6-compatible rouge to avoid resolver failure.
|
|
69
|
+
local ruby_ver; ruby_ver=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
|
|
70
|
+
if [[ "$ruby_ver" == 2.6.* ]]; then
|
|
71
|
+
print_warning "Ruby 2.6 detected — pinning rouge 3.30.0 first"
|
|
72
|
+
gem install rouge -v 3.30.0 --no-document "${source_args[@]}" || { print_error "gem install rouge failed"; return 1; }
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if gem install "$target" --no-document "${source_args[@]}"; then
|
|
66
76
|
print_success "${DISPLAY_NAME} installed successfully!"
|
|
67
77
|
return 0
|
|
68
|
-
else
|
|
69
|
-
print_error "gem install failed"; return 1
|
|
70
78
|
fi
|
|
79
|
+
|
|
80
|
+
print_error "gem install failed"; return 1
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
# --------------------------------------------------------------------------
|
data/scripts/install.sh
CHANGED
|
@@ -421,6 +421,7 @@ install_via_gem() {
|
|
|
421
421
|
configure_gem_source
|
|
422
422
|
setup_gem_home
|
|
423
423
|
|
|
424
|
+
local target source_args=()
|
|
424
425
|
if [ "$USE_CN_MIRRORS" = true ]; then
|
|
425
426
|
print_info "Fetching latest version from OSS..."
|
|
426
427
|
local cn_version; cn_version=$(curl -fsSL "$CN_GEM_LATEST_URL" | tr -d '[:space:]')
|
|
@@ -429,17 +430,26 @@ install_via_gem() {
|
|
|
429
430
|
local gem_file="/tmp/openclacky-${cn_version}.gem"
|
|
430
431
|
print_info "Downloading openclacky-${cn_version}.gem..."
|
|
431
432
|
curl -fsSL "$gem_url" -o "$gem_file"
|
|
432
|
-
|
|
433
|
+
target="$gem_file"
|
|
434
|
+
source_args=(--source "$CN_RUBYGEMS_URL")
|
|
433
435
|
else
|
|
434
|
-
|
|
436
|
+
target="openclacky"
|
|
435
437
|
fi
|
|
436
438
|
|
|
437
|
-
|
|
439
|
+
# macOS system Ruby 2.6 has a buggy gem resolver that fails on rouge 4.x.
|
|
440
|
+
# Pre-install a 2.6-compatible rouge to avoid resolver failure.
|
|
441
|
+
local ruby_ver; ruby_ver=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
|
|
442
|
+
if [[ "$ruby_ver" == 2.6.* ]]; then
|
|
443
|
+
print_warning "Ruby 2.6 detected — pinning rouge 3.30.0 first"
|
|
444
|
+
gem install rouge -v 3.30.0 --no-document "${source_args[@]}" || { print_error "gem install rouge failed"; return 1; }
|
|
445
|
+
fi
|
|
446
|
+
|
|
447
|
+
if gem install "$target" --no-document "${source_args[@]}"; then
|
|
438
448
|
print_success "${DISPLAY_NAME} installed successfully!"
|
|
439
449
|
return 0
|
|
440
|
-
else
|
|
441
|
-
print_error "gem install failed"; return 1
|
|
442
450
|
fi
|
|
451
|
+
|
|
452
|
+
print_error "gem install failed"; return 1
|
|
443
453
|
}
|
|
444
454
|
|
|
445
455
|
# --------------------------------------------------------------------------
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openclacky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|