openclacky 1.5.0 → 1.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85707cf41b28f7283581fde4a77cbdf136ac525d9671743c13cfe268cf71c230
4
- data.tar.gz: f2ccf13cf87f8aeb4e9ac2a27a7f7d3f1c8be53f196d52b39d412ae54bbd791a
3
+ metadata.gz: bb5f68cdd24e5d619fa22b79c643953e6c00f54400d7e89d95a9c99373328a55
4
+ data.tar.gz: 5bc17c114482d78e13d59d5f6b194caffbf9456015bce6082d3bbc63bdf951a2
5
5
  SHA512:
6
- metadata.gz: 33a52baaf3983e25e121d6e5200424d57b20d50af5226168df47b730e202add9ab296ecf9d480dc1380302d95b24273ad399b808290954875a139748c4879de8
7
- data.tar.gz: c06c55d0765c8f657606dae02f214aeb938dc484522af5698bcd408b34379160e2bd850a9abdc91a82d4bf3f9925a20b698c3973a35839bb8886170f10d4215d
6
+ metadata.gz: 55044dd2a193d5ed5e0b64aefee284fec18cb036369482d55936e7c3c01d8740aecee16e0ca75f1e5d1cc643a4d70d66005f1f5eb61d3bfc02a6152ef3b4155d
7
+ data.tar.gz: 86de30d32d826921245ea60abd5229665eb82445b68f68e6a68f90b519d4b0dd900b077a738c82b65a1d78a3bc974b3d938986445e9149f074b94095ead044c9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.2] - 2026-07-27
8
+
9
+ ### Added
10
+ - Starter prompts for ext-developer agent on new session page
11
+ - Secondary gateway endpoint for OpenClacky provider with auto-fallback after all retries exhausted
12
+ - Subagent save support and invoke_skill format improvements
13
+
14
+ ### Improved
15
+ - Curly quotes wrapping for starter prompt text
16
+ - Brand license badge now distinguishes unreachable vs expiring states
17
+
18
+ ### Fixed
19
+ - HTTP 429 quota_exceeded now shows correct "quota exceeded" error instead of "rate limit" and stops retrying
20
+ - Search query filter now applies correctly to brand extensions in marketplace
21
+ - Billing page colors unified to warm palette
22
+ - Sub-model switching and card display now use saved provider_id correctly
23
+ - Device revoked error message now includes 15-minute wait hint
24
+ - Extension detail page routes by source param for brand/public distinction
25
+ - Non-slug skill names preserved as name_zh fallback for Chinese matching
26
+ - Sudo and .env writes unblocked in subagent; double-compression skipped
27
+ - Claude tool call ID handling fixed
28
+ - Brand skill preserved when auto-update download fails (C-5754)
29
+ - Extension packing rejected when folder name diverges from ext.yml id (C-5752)
30
+
31
+ ## [1.5.1] - 2026-07-24
32
+
33
+ ### Fixed
34
+ - Extension marketplace detail page now opens correctly for brand users when clicking public extensions
35
+ - Uninstall script now properly removes all installed openclacky gem versions
36
+
7
37
  ## [1.5.0] - 2026-07-23
8
38
 
9
39
  ### Added
@@ -201,6 +201,20 @@ module Clacky
201
201
  sleep retry_delay
202
202
  retry
203
203
  else
204
+ # All retries exhausted for a timeout. Try the secondary gateway URL if available.
205
+ if !@config.url_fallback_active?
206
+ fallback_url = @config.fallback_base_url_for_current_provider
207
+ if fallback_url
208
+ @config.activate_url_fallback!(fallback_url)
209
+ rebuild_client_for_current_model!
210
+ retries = 0
211
+ @ui&.show_warning(
212
+ I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
213
+ )
214
+ retry
215
+ end
216
+ end
217
+
204
218
  raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
205
219
  end
206
220
 
@@ -234,6 +248,21 @@ module Clacky
234
248
  sleep retry_delay
235
249
  retry
236
250
  else
251
+ # All retries on the primary endpoint exhausted for a network-level
252
+ # failure (DNS/TCP/TLS). Try the secondary gateway URL if available.
253
+ if !@config.url_fallback_active?
254
+ fallback_url = @config.fallback_base_url_for_current_provider
255
+ if fallback_url
256
+ @config.activate_url_fallback!(fallback_url)
257
+ rebuild_client_for_current_model!
258
+ retries = 0
259
+ @ui&.show_warning(
260
+ I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
261
+ )
262
+ retry
263
+ end
264
+ end
265
+
237
266
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
238
267
  # Progress cleanup is the caller's responsibility (via its own ensure block).
239
268
  raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
@@ -272,6 +301,25 @@ module Clacky
272
301
  sleep retry_delay
273
302
  retry
274
303
  else
304
+ # All retries on the current endpoint exhausted.
305
+ # If a secondary gateway URL is configured and not yet tried, switch
306
+ # to it now, rebuild the client, and reset the retry counter so the
307
+ # same request gets a fresh MAX_RETRIES_ON_FALLBACK budget on the
308
+ # new endpoint. This is a one-shot switch — url_fallback_active?
309
+ # prevents a second switch if the fallback URL also fails.
310
+ if !@config.url_fallback_active?
311
+ fallback_url = @config.fallback_base_url_for_current_provider
312
+ if fallback_url
313
+ @config.activate_url_fallback!(fallback_url)
314
+ rebuild_client_for_current_model!
315
+ retries = 0
316
+ @ui&.show_warning(
317
+ I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
318
+ )
319
+ retry
320
+ end
321
+ end
322
+
275
323
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
276
324
  # Progress cleanup is the caller's responsibility (via its own ensure block).
277
325
  raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
@@ -122,6 +122,13 @@ module Clacky
122
122
  # handle_compression_response, so recent task progress is preserved.
123
123
  # @return [Hash, nil] Compression context or nil if not needed
124
124
  def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
125
+ # Subagents must not proactively compress — their history is a clone of the
126
+ # parent's and will be discarded when the subagent exits. The parent will
127
+ # compress its own history after the subagent returns, so doing it here is
128
+ # pure waste (same data compressed twice). Context-overflow recovery
129
+ # (force: true) is still allowed as a safety net for long-running subagents.
130
+ return nil if @is_subagent && !force
131
+
125
132
  # Check if compression is enabled
126
133
  return nil unless @config.enable_compression
127
134
 
@@ -641,13 +641,55 @@ module Clacky
641
641
 
642
642
  ui.show_tool_result(blk[:content].to_s)
643
643
  end
644
+ replay_subagent_transcript(msg, ui)
644
645
 
645
646
  when "tool"
646
647
  # OpenAI-format tool result
647
648
  ui.show_tool_result(msg[:content].to_s)
649
+ replay_subagent_transcript(msg, ui)
648
650
  end
649
651
  end
650
652
 
653
+ # Replay a subagent transcript stored on a tool result message. Emits a
654
+ # bracketed sequence of UI events the frontend can render as a collapsible
655
+ # sub-process block: subagent_start → (assistant_message / tool_call /
656
+ # tool_result)* → subagent_end. No-op when the message carries no transcript.
657
+ def replay_subagent_transcript(msg, ui)
658
+ transcript = msg[:subagent_transcript]
659
+ return unless transcript.is_a?(Hash)
660
+
661
+ events = transcript[:events] || transcript["events"] || []
662
+ return if events.empty?
663
+
664
+ ui.show_subagent_start(
665
+ skill: transcript[:skill] || transcript["skill"],
666
+ iterations: transcript[:iterations] || transcript["iterations"],
667
+ cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
668
+ )
669
+
670
+ events.each do |ev|
671
+ role = (ev[:role] || ev["role"]).to_s
672
+ content = ev[:content] || ev["content"]
673
+ tool_calls = ev[:tool_calls] || ev["tool_calls"]
674
+
675
+ case role
676
+ when "assistant"
677
+ text = extract_text_from_content(content).to_s.strip
678
+ ui.show_assistant_message(text, files: []) unless text.empty?
679
+ Array(tool_calls).each do |tc|
680
+ name = tc[:name] || tc["name"] || ""
681
+ args_raw = tc[:arguments] || tc["arguments"] || {}
682
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
683
+ ui.show_tool_call(name, args)
684
+ end
685
+ when "tool", "user"
686
+ ui.show_tool_result(extract_text_from_content(content).to_s)
687
+ end
688
+ end
689
+
690
+ ui.show_subagent_end
691
+ end
692
+
651
693
  # Replace the system message in @messages with a freshly built system prompt.
652
694
  # Called after restore_session so newly installed skills and any other
653
695
  # configuration changes since the session was saved take effect immediately.
@@ -639,6 +639,13 @@ module Clacky
639
639
  # Generate summary
640
640
  summary = generate_subagent_summary(subagent)
641
641
 
642
+ # Capture the subagent's own message trail (everything it appended after
643
+ # the fork) so the parent session can persist and later replay the full
644
+ # subagent process — not just the collapsed summary. Stored transiently
645
+ # on the parent agent; observe() attaches it to the invoke_skill tool
646
+ # result message. Kept out of the LLM payload via INTERNAL_FIELDS.
647
+ @pending_subagent_transcript = extract_subagent_transcript(subagent, skill.identifier)
648
+
642
649
  # Mutate the subagent_instructions message in-place to become the result summary
643
650
  @history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
644
651
  m[:content] = summary
data/lib/clacky/agent.rb CHANGED
@@ -105,6 +105,7 @@ module Clacky
105
105
  @ui = ui # UIController for direct UI interaction
106
106
  @debug_logs = [] # Debug logs for troubleshooting
107
107
  @pending_injections = [] # Pending inline skill injections to flush after observe()
108
+ @pending_subagent_transcript = nil # Subagent trail to attach to the next tool result (observe)
108
109
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
109
110
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
110
111
  @last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
@@ -212,7 +213,7 @@ module Clacky
212
213
  private def rebuild_client_for_current_model!
213
214
  @client = Clacky::Client.new(
214
215
  @config.api_key,
215
- base_url: @config.base_url,
216
+ base_url: @config.effective_base_url,
216
217
  model: @config.model_name,
217
218
  anthropic_format: @config.anthropic_format?
218
219
  )
@@ -249,6 +250,7 @@ module Clacky
249
250
  id: model["id"],
250
251
  model: model["model"],
251
252
  base_url: model["base_url"],
253
+ provider_id: model["provider_id"],
252
254
  card_model: base_entry&.dig("model"),
253
255
  sub_model: sub_model
254
256
  }
@@ -1138,6 +1140,8 @@ module Clacky
1138
1140
  @history.append(truncated.merge(task_id: @current_task_id))
1139
1141
  end
1140
1142
 
1143
+ attach_pending_subagent_transcript(response)
1144
+
1141
1145
  # Append a follow-up `role:"user"` message for any image payloads that
1142
1146
  # could not be delivered inside the tool message.
1143
1147
  #
@@ -1181,6 +1185,24 @@ module Clacky
1181
1185
  end
1182
1186
  end
1183
1187
 
1188
+ # Attach the captured subagent transcript (set by execute_skill_with_subagent)
1189
+ # to the invoke_skill tool result message just appended by observe(). The
1190
+ # transcript rides on the tool message as :subagent_transcript — an internal
1191
+ # field stripped before the LLM call but persisted to session.json and
1192
+ # replayed to the WebUI. Cleared after attaching so it fires exactly once.
1193
+ private def attach_pending_subagent_transcript(response)
1194
+ transcript = @pending_subagent_transcript
1195
+ return unless transcript
1196
+
1197
+ @pending_subagent_transcript = nil
1198
+
1199
+ skill_call = Array(response[:tool_calls]).find { |tc| (tc[:name] || tc.dig(:function, :name)) == "invoke_skill" }
1200
+ target_id = skill_call && skill_call[:id]
1201
+ return unless target_id
1202
+
1203
+ @history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
1204
+ end
1205
+
1184
1206
  # Cap oversized tool result content to keep a single tool message from
1185
1207
  # blowing up the prompt budget (issue #218: a 7350-path glob produced a
1186
1208
  # ~890k-char result that pushed history past the model context window
@@ -1541,6 +1563,41 @@ module Clacky
1541
1563
  parts.join("\n")
1542
1564
  end
1543
1565
 
1566
+ # Extract the subagent's own message trail for persistence/replay.
1567
+ # Returns a trimmed, LLM-free array of {role, content, tool_calls} hashes
1568
+ # capturing only what the subagent did after the fork — system-injected
1569
+ # scaffolding (fork instructions, ack) is dropped. Stored on the parent's
1570
+ # invoke_skill tool result under :subagent_transcript so the WebUI can
1571
+ # render a collapsible sub-process without polluting the main thread.
1572
+ def extract_subagent_transcript(subagent, skill_identifier)
1573
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1574
+ new_messages = subagent.history.to_a[parent_count..] || []
1575
+
1576
+ events = new_messages.filter_map do |m|
1577
+ next if m[:system_injected]
1578
+ role = m[:role].to_s
1579
+ next unless %w[assistant tool user].include?(role)
1580
+
1581
+ entry = { role: role }
1582
+ entry[:content] = m[:content] if m[:content] && !m[:content].to_s.empty?
1583
+ if m[:tool_calls].is_a?(Array) && !m[:tool_calls].empty?
1584
+ entry[:tool_calls] = m[:tool_calls].map do |tc|
1585
+ func = tc[:function] || tc
1586
+ { name: func[:name] || tc[:name], arguments: func[:arguments] || tc[:arguments] || {} }
1587
+ end
1588
+ end
1589
+ entry[:tool_call_id] = m[:tool_call_id] if m[:tool_call_id]
1590
+ entry.key?(:content) || entry.key?(:tool_calls) ? entry : nil
1591
+ end
1592
+
1593
+ {
1594
+ skill: skill_identifier,
1595
+ iterations: subagent.iterations,
1596
+ cost_usd: subagent.total_cost.round(4),
1597
+ events: events
1598
+ }
1599
+ end
1600
+
1544
1601
  # Deep clone helper for messages using Marshal
1545
1602
  # @param obj [Object] Object to clone
1546
1603
  # @return [Object] Deep cloned object
@@ -1053,6 +1053,48 @@ module Clacky
1053
1053
  end
1054
1054
  end
1055
1055
 
1056
+ # ── URL-level fallback ─────────────────────────────────────────────────
1057
+ # Independent from the model-name fallback above. When all max_retries
1058
+ # on the primary endpoint are exhausted, the caller may switch to a
1059
+ # secondary gateway URL (same model, different host) via these methods.
1060
+ # The URL fallback is intentionally one-shot per session — we do not
1061
+ # probe or reset it automatically, keeping the logic simple.
1062
+
1063
+ # Look up the fallback base URL for the current model's provider.
1064
+ # Returns nil if the provider has no secondary gateway configured.
1065
+ # @return [String, nil]
1066
+ def fallback_base_url_for_current_provider
1067
+ m = current_model
1068
+ return nil unless m
1069
+
1070
+ provider_id = Clacky::Providers.resolve_provider(
1071
+ base_url: m["base_url"],
1072
+ api_key: m["api_key"]
1073
+ )
1074
+ return nil unless provider_id
1075
+
1076
+ Clacky::Providers.fallback_base_url(provider_id)
1077
+ end
1078
+
1079
+ # Activate the URL fallback: override base_url with the secondary gateway.
1080
+ # Idempotent — safe to call multiple times.
1081
+ # @param fallback_url [String] the secondary gateway URL
1082
+ def activate_url_fallback!(fallback_url)
1083
+ @url_fallback_active = true
1084
+ @url_fallback_base_url = fallback_url
1085
+ end
1086
+
1087
+ # Returns true when the secondary gateway URL is in use.
1088
+ def url_fallback_active?
1089
+ @url_fallback_active == true
1090
+ end
1091
+
1092
+ # The effective base URL for API calls.
1093
+ # Returns the URL-fallback override when active, otherwise the model's configured base_url.
1094
+ def effective_base_url
1095
+ @url_fallback_active ? @url_fallback_base_url : base_url
1096
+ end
1097
+
1056
1098
  # Get current model configuration.
1057
1099
  #
1058
1100
  # Resolution order:
@@ -40,6 +40,16 @@ module Clacky
40
40
  # Grace period for offline heartbeat failures (3 days)
41
41
  HEARTBEAT_GRACE_PERIOD = 3 * 86_400
42
42
 
43
+ # Per-slug locks serializing concurrent brand-skill installs. Each Agent
44
+ # spawns its own background sync thread, so several may try to install the
45
+ # same skill at once; a shared per-slug mutex collapses that to one download.
46
+ @install_locks = {}
47
+ @install_locks_guard = Mutex.new
48
+
49
+ def self.install_lock_for(slug)
50
+ @install_locks_guard.synchronize { @install_locks[slug] ||= Mutex.new }
51
+ end
52
+
43
53
  attr_reader :product_name, :package_name, :license_key, :license_activated_at,
44
54
  :license_expires_at, :license_last_heartbeat, :device_id,
45
55
  :logo_url, :support_contact, :license_user_id,
@@ -1106,72 +1116,91 @@ module Clacky
1106
1116
 
1107
1117
  require "zip"
1108
1118
 
1109
- dest_dir = File.join(brand_skills_dir, slug)
1110
- FileUtils.mkdir_p(dest_dir)
1119
+ # Serialize installs of the same slug so concurrent background syncs (one
1120
+ # per Agent) don't redundantly download the same ZIP or race on the same
1121
+ # destination directory.
1122
+ install_lock = self.class.install_lock_for(slug)
1123
+ install_lock.synchronize do
1124
+ dest_dir = File.join(brand_skills_dir, slug)
1125
+
1126
+ # Download and extract into a unique staging directory, never touching
1127
+ # dest_dir until everything succeeds. A failed or corrupt download must
1128
+ # never destroy the already-installed version.
1129
+ stage_id = "#{Process.pid}.#{SecureRandom.hex(4)}"
1130
+ stage_dir = File.join(brand_skills_dir, ".staging-#{slug}-#{stage_id}")
1131
+ tmp_zip = File.join(brand_skills_dir, ".#{slug}-#{stage_id}.zip")
1132
+ FileUtils.mkdir_p(stage_dir)
1133
+
1134
+ begin
1135
+ # Download the zip file to a temp path via PlatformHttpClient so the
1136
+ # primary → fallback host failover applies uniformly to every download.
1137
+ dl = platform_client.download_file(url, tmp_zip)
1138
+ raise dl[:error].to_s unless dl[:success]
1139
+
1140
+ zip_size = File.size?(tmp_zip).to_i
1141
+ raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
1142
+
1143
+ # Extract into stage_dir.
1144
+ # Auto-detect whether the zip has a single root folder to strip.
1145
+ # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
1146
+ # path-safety restrictions on absolute destination paths.
1147
+ # Uses chunked read + size verification for robustness.
1148
+ Zip::File.open(tmp_zip) do |zip|
1149
+ entries = zip.entries.reject(&:directory?)
1150
+ top_dirs = entries.map { |e| e.name.split("/").first }.uniq
1151
+ has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
1152
+
1153
+ entries.each do |entry|
1154
+ rel_path = if has_root
1155
+ parts = entry.name.split("/")
1156
+ parts[1..].join("/")
1157
+ else
1158
+ entry.name
1159
+ end
1160
+
1161
+ next if rel_path.nil? || rel_path.empty?
1162
+
1163
+ out = File.join(stage_dir, rel_path)
1164
+ FileUtils.mkdir_p(File.dirname(out))
1165
+
1166
+ # Chunked copy with size verification
1167
+ written = 0
1168
+ File.open(out, "wb") do |f|
1169
+ entry.get_input_stream do |input|
1170
+ while (chunk = input.read(65536))
1171
+ f.write(chunk)
1172
+ written += chunk.bytesize
1173
+ end
1174
+ end
1175
+ end
1111
1176
 
1112
- # Download the zip file to a temp path via PlatformHttpClient so the
1113
- # primary fallback host failover applies uniformly to every download.
1114
- tmp_zip = File.join(brand_skills_dir, "#{slug}.zip")
1115
- dl = platform_client.download_file(url, tmp_zip)
1116
- raise dl[:error].to_s unless dl[:success]
1117
-
1118
- zip_size = File.size?(tmp_zip).to_i
1119
- raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
1120
-
1121
- # Extract into dest_dir (overwrite existing files).
1122
- # Auto-detect whether the zip has a single root folder to strip.
1123
- # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
1124
- # path-safety restrictions on absolute destination paths.
1125
- # Uses chunked read + size verification for robustness.
1126
- Zip::File.open(tmp_zip) do |zip|
1127
- entries = zip.entries.reject(&:directory?)
1128
- top_dirs = entries.map { |e| e.name.split("/").first }.uniq
1129
- has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
1130
-
1131
- entries.each do |entry|
1132
- rel_path = if has_root
1133
- parts = entry.name.split("/")
1134
- parts[1..].join("/")
1135
- else
1136
- entry.name
1137
- end
1138
-
1139
- next if rel_path.nil? || rel_path.empty?
1140
-
1141
- out = File.join(dest_dir, rel_path)
1142
- FileUtils.mkdir_p(File.dirname(out))
1143
-
1144
- # Chunked copy with size verification
1145
- written = 0
1146
- File.open(out, "wb") do |f|
1147
- entry.get_input_stream do |input|
1148
- while (chunk = input.read(65536))
1149
- f.write(chunk)
1150
- written += chunk.bytesize
1177
+ # Verify file size matches ZIP entry declaration
1178
+ if written != entry.size
1179
+ raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}"
1151
1180
  end
1152
1181
  end
1153
1182
  end
1154
1183
 
1155
- # Verify file size matches ZIP entry declaration
1156
- if written != entry.size
1157
- raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}"
1158
- end
1184
+ # Everything extracted successfully atomically swap staging into
1185
+ # place. Only now is the previous version removed.
1186
+ FileUtils.rm_rf(dest_dir)
1187
+ FileUtils.mv(stage_dir, dest_dir)
1188
+
1189
+ record_installed_skill(slug, version, skill_info["description"],
1190
+ encrypted: encrypted,
1191
+ description_zh: skill_info["description_zh"],
1192
+ name_zh: skill_info["name_zh"])
1193
+
1194
+ { success: true, name: slug, version: version }
1195
+ rescue StandardError, ScriptError => e
1196
+ # Only clean up our own staging artifacts; the installed version in
1197
+ # dest_dir is left untouched so a failed update never loses a skill.
1198
+ { success: false, error: e.message }
1199
+ ensure
1200
+ FileUtils.rm_f(tmp_zip)
1201
+ FileUtils.rm_rf(stage_dir) if Dir.exist?(stage_dir)
1159
1202
  end
1160
1203
  end
1161
-
1162
- FileUtils.rm_f(tmp_zip)
1163
-
1164
-
1165
- record_installed_skill(slug, version, skill_info["description"],
1166
- encrypted: encrypted,
1167
- description_zh: skill_info["description_zh"],
1168
- name_zh: skill_info["name_zh"])
1169
-
1170
- { success: true, name: slug, version: version }
1171
- rescue StandardError, ScriptError => e
1172
- FileUtils.rm_f(tmp_zip) if defined?(tmp_zip) && tmp_zip
1173
- FileUtils.rm_rf(dest_dir) if defined?(dest_dir) && dest_dir
1174
- { success: false, error: e.message }
1175
1204
  end
1176
1205
 
1177
1206
  # Install a mock brand skill for brand-test mode.
data/lib/clacky/client.rb CHANGED
@@ -616,7 +616,7 @@ module Clacky
616
616
  when 401 then I18n.t("llm.error.invalid_api_key")
617
617
  when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
618
618
  when 404 then I18n.t("llm.error.endpoint_not_found")
619
- when 429 then I18n.t("llm.error.rate_limit_429")
619
+ when 429 then error_code == "quota_exceeded" ? I18n.t("llm.error.quota_exhausted") : I18n.t("llm.error.rate_limit_429")
620
620
  when 500..599 then I18n.t("llm.error.server_error", status: response.status)
621
621
  else extract_error_message(error_body, response.body)
622
622
  end
@@ -670,7 +670,11 @@ module Clacky
670
670
  raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
671
671
  when 404
672
672
  raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
673
- when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
673
+ when 429
674
+ if error_code == "quota_exceeded"
675
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.quota_exhausted")}", raw_message: error_message)
676
+ end
677
+ raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
674
678
  when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
675
679
  else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
676
680
  end
@@ -51,6 +51,7 @@
51
51
  "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
52
  "err.invalid_version": "Invalid version. Use format like 1.0.0.",
53
53
  "err.name_taken": "The name \"{{id}}\" is already taken by another user.",
54
+ "err.id_folder_mismatch": "Extension ID mismatch: the folder is named \"{{folder}}\" but ext.yml declares id \"{{id}}\". Rename the folder or the id so they match before publishing.",
54
55
  "rename.label": "Choose a new ID for your extension:",
55
56
  "rename.placeholder": "e.g. my-ext-yourname",
56
57
  "rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
@@ -235,6 +236,7 @@
235
236
  "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
236
237
  "err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
237
238
  "err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
239
+ "err.id_folder_mismatch": "扩展 ID 不一致:文件夹名为「{{folder}}」,但 ext.yml 声明的 id 是「{{id}}」。请先把文件夹名或 id 改成一致再发布。",
238
240
  "rename.label": "请为你的扩展选择一个新 ID:",
239
241
  "rename.placeholder": "例如 my-ext-yourname",
240
242
  "rename.hint": "只能使用小写字母、数字和连字符。",
@@ -726,6 +728,7 @@
726
728
  } catch (e) {
727
729
  const msg = e.message || "";
728
730
  const isConflict = /must be greater than/i.test(msg);
731
+ const mismatch = msg.match(/folder is '([^']+)' but ext\.yml declares id '([^']+)'/);
729
732
  if (isConflict) {
730
733
  setProgress(t("err.version_conflict", { ver }), true);
731
734
  verInput.classList.add("studio-input-error");
@@ -733,6 +736,8 @@
733
736
  verInput.focus();
734
737
  verInput.select();
735
738
  verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
739
+ } else if (mismatch) {
740
+ setProgress(t("err.id_folder_mismatch", { folder: mismatch[1], id: mismatch[2] }), true);
736
741
  } else {
737
742
  setProgress(t("err.generic", { msg }), true);
738
743
  }
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
5
  require "open-uri"
6
+ require "psych"
6
7
  require "zip"
7
8
 
8
9
  module Clacky
@@ -38,6 +39,7 @@ module Clacky
38
39
  raise Error, "no container found at #{container_dir} (expected #{MANIFEST})"
39
40
  end
40
41
 
42
+ refuse_id_folder_mismatch!(slug, container_dir)
41
43
  verify_container!(slug, container_dir)
42
44
  refuse_protected!(slug, container_dir)
43
45
 
@@ -96,6 +98,21 @@ module Clacky
96
98
  Clacky::ExtensionLoader.invalidate_cache!
97
99
  end
98
100
 
101
+ # The container's identity must be the same on both sides of the publish
102
+ # boundary: the client keys extensions by folder name, the marketplace
103
+ # keys them by ext.yml `id`. If they diverge, a rename on one side alone
104
+ # lets a container masquerade under a name the uniqueness check never saw.
105
+ private def refuse_id_folder_mismatch!(slug, container_dir)
106
+ manifest = File.join(container_dir, MANIFEST)
107
+ data = Psych.safe_load(File.read(manifest), permitted_classes: [], aliases: true) || {}
108
+ declared = data["id"].to_s.strip
109
+ return if declared == slug
110
+
111
+ raise Error,
112
+ "extension id mismatch: folder is '#{slug}' but #{MANIFEST} declares id '#{declared}'. " \
113
+ "Rename the folder or the id so they match before packing."
114
+ end
115
+
99
116
  # Packing is for authored (open) containers. Anything already protected or
100
117
  # carrying encrypted skills is produced by the platform pipeline; refuse
101
118
  # to re-pack it so we never smuggle marketplace artifacts out of band.
@@ -9,11 +9,12 @@ module Clacky
9
9
  "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
10
  "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
11
  "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
12
- "llm.error.403.quota_exceeded" => "Quota exceeded, please upgrade your plan",
12
+ "llm.error.403.quota_exceeded" => "API key quota exceeded. Please update the quota or disable the limit in the admin console",
13
13
  "llm.error.403.access_denied" => "Access denied, please check your API key permissions",
14
14
  "llm.error.403.default" => "Access denied",
15
15
  "llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
16
16
  "llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
17
+ "llm.error.quota_exhausted" => "API quota exhausted (key usage limit reached). Please raise the quota or upgrade your plan in the console",
17
18
  "llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
18
19
  "llm.error.unexpected" => "Unexpected error (%<status>d)",
19
20
  "llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
@@ -21,6 +22,7 @@ module Clacky
21
22
  "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
22
23
  "llm.error.network_failed" => "Network connection failed after %<retries>d retries",
23
24
  "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
25
+ "llm.warn.switching_to_fallback_url" => "Primary endpoint unreachable. Switching to fallback gateway: %<url>s",
24
26
  "platform.error.invalid_proof" => "Invalid license key — please check and try again.",
25
27
  "platform.error.invalid_signature" => "Invalid request signature.",
26
28
  "platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
@@ -28,7 +30,7 @@ module Clacky
28
30
  "platform.error.license_revoked" => "This license has been revoked. Please contact support.",
29
31
  "platform.error.license_expired" => "This license has expired. Please renew to continue.",
30
32
  "platform.error.device_limit_reached" => "Device limit reached for this license.",
31
- "platform.error.device_revoked" => "This device has been revoked from the license.",
33
+ "platform.error.device_revoked" => "This device has been revoked from the license. To re-activate, please wait 15 minutes and try again.",
32
34
  "platform.error.invalid_license" => "License key not found. Please verify the key.",
33
35
  "platform.error.device_not_found" => "Device not registered. Please re-activate.",
34
36
  "platform.error.contributor_required" => "Publishing extensions requires becoming a contributor. Sign in, open \"My Extensions\", and click \"Become a contributor\" (no review needed).",