openclacky 1.5.1 → 1.5.3

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/agent/llm_caller.rb +48 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +7 -0
  5. data/lib/clacky/agent/session_serializer.rb +42 -0
  6. data/lib/clacky/agent/skill_manager.rb +7 -0
  7. data/lib/clacky/agent.rb +58 -1
  8. data/lib/clacky/agent_config.rb +71 -1
  9. data/lib/clacky/brand_config.rb +88 -59
  10. data/lib/clacky/client.rb +13 -2
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
  13. data/lib/clacky/extension/packager.rb +17 -0
  14. data/lib/clacky/locales/en.rb +4 -2
  15. data/lib/clacky/locales/zh.rb +5 -3
  16. data/lib/clacky/message_format/anthropic.rb +56 -1
  17. data/lib/clacky/message_format/open_ai.rb +69 -4
  18. data/lib/clacky/message_history.rb +15 -1
  19. data/lib/clacky/providers.rb +73 -6
  20. data/lib/clacky/server/http_server.rb +61 -29
  21. data/lib/clacky/server/session_registry.rb +4 -2
  22. data/lib/clacky/skill.rb +4 -0
  23. data/lib/clacky/tools/invoke_skill.rb +21 -0
  24. data/lib/clacky/tools/security.rb +11 -13
  25. data/lib/clacky/ui_interface.rb +2 -0
  26. data/lib/clacky/utils/model_pricing.rb +8 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +210 -21
  29. data/lib/clacky/web/app.js +8 -3
  30. data/lib/clacky/web/components/code-editor.js +50 -12
  31. data/lib/clacky/web/features/billing/view.js +7 -1
  32. data/lib/clacky/web/features/extensions/store.js +23 -7
  33. data/lib/clacky/web/features/extensions/view.js +8 -4
  34. data/lib/clacky/web/features/new-session/view.js +61 -0
  35. data/lib/clacky/web/features/workspace/store.js +9 -0
  36. data/lib/clacky/web/features/workspace/view.js +8 -1
  37. data/lib/clacky/web/i18n.js +14 -0
  38. data/lib/clacky/web/index.html +4 -2
  39. data/lib/clacky/web/settings.js +49 -14
  40. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b195de6fb8b38aed47ef40aa59fa0e33aa3b993f52cc584d5c12bd27bd15b55c
4
- data.tar.gz: 25b4a406f7f4985e4bdad2cd63c28c2af8875bed7ddf59baae33a94d9ebbe46b
3
+ metadata.gz: 04d2d81c4aaa380d4600066802c78e1137bdad1cb16c3e5e47e32e69ede15da6
4
+ data.tar.gz: 9a332e0305e96bae97799ce707ef492b24735e33ef8da70a390c06a10ac11ab4
5
5
  SHA512:
6
- metadata.gz: 15a21c8d7aa23bbccc4a8255cb2caa14a937ff92ec90cbf20fac58a71675acfdd1f64be47723151841f860ed069add58e43abdccb48b31233cde8351ab9fad23
7
- data.tar.gz: 3fbaadab5fcf374369ece22818f2739839ebf974d91a0ecf1dba9aa9e0530e27a801e4366ab2f6403c222501601e4e76f736d73d81eb667ee275e150364d21a1
6
+ metadata.gz: 435b6c1bd6aac4c2916cf172e5d46dece9a5707d1b18119d4744d29293c1f7b8fb112709718d24b8b9b3a871bfa228294a282525d9b6f7ed14550b89eaaa2793
7
+ data.tar.gz: 90599042f3d933db9b7681c33d539937585cd85542d635696ea159056aa47dbe34f86971bd1db6541c4dee28ee73c66e8f4648d91d8f3b1897b179ce9a2b00fc
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ 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.3] - 2026-07-28
8
+
9
+ ### Added
10
+ - Per-model max output token limits (MODEL_MAX_OUTPUT) applied automatically when sending requests
11
+ - GLM-5.2 added to preset model lineup with pricing table
12
+ - MiMo-V2.5 thinking mode support with max_completion_tokens
13
+ - reasoning_effort mapped to GLM thinking mode and Kimi-K3 thinking levels
14
+ - File-tree save support added to CodeEditor with markdown split-preview panel
15
+ - Unlisted badge and source=brand routing for brand-private extensions
16
+
17
+ ### Improved
18
+ - Markdown preview styles refined: hr, inline images, tables, modal sizing
19
+ - OpenAI client refactored with token_field_for and apply_reasoning_params helpers
20
+
21
+ ### Fixed
22
+ - URL fallback state (base URL) now resets correctly when switching models in quick switch
23
+ - Kimi K3 now uses correct thinking:{type:'enabled'} format for Anthropic client
24
+ - MiMo preset aligned with V2.5 lineup and Token Plan endpoint
25
+ - ext-studio sessions no longer incorrectly tagged as setup sessions (C-5722)
26
+ - localhost URLs no longer trigger OpenClacky failover (find_by_base_url guard)
27
+ - API key toggle icon default and click states corrected
28
+ - Share button on billing page now uses SVG icon instead of emoji
29
+ - Share button hover border color uses accent-primary token
30
+
31
+ ## [1.5.2] - 2026-07-27
32
+
33
+ ### Added
34
+ - Starter prompts for ext-developer agent on new session page
35
+ - Secondary gateway endpoint for OpenClacky provider with auto-fallback after all retries exhausted
36
+ - Subagent save support and invoke_skill format improvements
37
+
38
+ ### Improved
39
+ - Curly quotes wrapping for starter prompt text
40
+ - Brand license badge now distinguishes unreachable vs expiring states
41
+
42
+ ### Fixed
43
+ - HTTP 429 quota_exceeded now shows correct "quota exceeded" error instead of "rate limit" and stops retrying
44
+ - Search query filter now applies correctly to brand extensions in marketplace
45
+ - Billing page colors unified to warm palette
46
+ - Sub-model switching and card display now use saved provider_id correctly
47
+ - Device revoked error message now includes 15-minute wait hint
48
+ - Extension detail page routes by source param for brand/public distinction
49
+ - Non-slug skill names preserved as name_zh fallback for Chinese matching
50
+ - Sudo and .env writes unblocked in subagent; double-compression skipped
51
+ - Claude tool call ID handling fixed
52
+ - Brand skill preserved when auto-update download fails (C-5754)
53
+ - Extension packing rejected when folder name diverges from ext.yml id (C-5752)
54
+
7
55
  ## [1.5.1] - 2026-07-24
8
56
 
9
57
  ### Fixed
@@ -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
@@ -476,7 +476,13 @@ module Clacky
476
476
  @current_model_id = id
477
477
  @current_model_index = index
478
478
 
479
- @session_model_overlay = nil if previous_id != id
479
+ if previous_id != id
480
+ @session_model_overlay = nil
481
+ # Reset URL fallback when switching models — the new model has its own
482
+ # base_url and the old fallback endpoint is irrelevant (or even wrong).
483
+ @url_fallback_active = false
484
+ @url_fallback_base_url = nil
485
+ end
480
486
 
481
487
  true
482
488
  end
@@ -492,9 +498,16 @@ module Clacky
492
498
  index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
493
499
  return false if index.nil?
494
500
 
501
+ previous_id = @current_model_id
495
502
  @current_model_id = @models[index]["id"]
496
503
  @current_model_index = index
497
504
 
505
+ if previous_id != @current_model_id
506
+ # Reset URL fallback when switching models — same rationale as switch_model_by_id.
507
+ @url_fallback_active = false
508
+ @url_fallback_base_url = nil
509
+ end
510
+
498
511
  true
499
512
  end
500
513
 
@@ -1053,6 +1066,63 @@ module Clacky
1053
1066
  end
1054
1067
  end
1055
1068
 
1069
+ # ── URL-level fallback ─────────────────────────────────────────────────
1070
+ # Independent from the model-name fallback above. When all max_retries
1071
+ # on the primary endpoint are exhausted, the caller may switch to a
1072
+ # secondary gateway URL (same model, different host) via these methods.
1073
+ # The URL fallback is intentionally one-shot per session — we do not
1074
+ # probe or reset it automatically, keeping the logic simple.
1075
+
1076
+ # Look up the fallback base URL for the current model's provider.
1077
+ # Returns nil if:
1078
+ # - provider cannot be determined
1079
+ # - provider is not openclacky (URL failover is only supported for the
1080
+ # OpenClacky gateway; other providers have no secondary endpoint)
1081
+ # - current base_url is already the fallback URL (user picked the
1082
+ # Secondary/China node directly — switching to the same URL does nothing)
1083
+ # @return [String, nil]
1084
+ def fallback_base_url_for_current_provider
1085
+ m = current_model
1086
+ return nil unless m
1087
+
1088
+ # Use strict URL matching to identify the provider — no inference.
1089
+ # resolve_provider would incorrectly return "openclacky" for localhost
1090
+ # addresses and clacky-* api keys, triggering unwanted failover.
1091
+ # find_by_base_url only matches registered preset URLs, so localhost
1092
+ # and other non-openclacky endpoints correctly return nil.
1093
+ provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
1094
+ return nil unless provider_id == "openclacky"
1095
+
1096
+ fallback_url = Clacky::Providers.fallback_base_url(provider_id)
1097
+ return nil unless fallback_url
1098
+
1099
+ # Don't offer a fallback when the current base_url is already the
1100
+ # fallback endpoint — switching to the same URL accomplishes nothing.
1101
+ current_base = m["base_url"].to_s.chomp("/")
1102
+ return nil if current_base == fallback_url.to_s.chomp("/")
1103
+
1104
+ fallback_url
1105
+ end
1106
+
1107
+ # Activate the URL fallback: override base_url with the secondary gateway.
1108
+ # Idempotent — safe to call multiple times.
1109
+ # @param fallback_url [String] the secondary gateway URL
1110
+ def activate_url_fallback!(fallback_url)
1111
+ @url_fallback_active = true
1112
+ @url_fallback_base_url = fallback_url
1113
+ end
1114
+
1115
+ # Returns true when the secondary gateway URL is in use.
1116
+ def url_fallback_active?
1117
+ @url_fallback_active == true
1118
+ end
1119
+
1120
+ # The effective base URL for API calls.
1121
+ # Returns the URL-fallback override when active, otherwise the model's configured base_url.
1122
+ def effective_base_url
1123
+ @url_fallback_active ? @url_fallback_base_url : base_url
1124
+ end
1125
+
1056
1126
  # Get current model configuration.
1057
1127
  #
1058
1128
  # 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
@@ -337,6 +337,13 @@ module Clacky
337
337
  # ── OpenAI request / response ─────────────────────────────────────────────
338
338
 
339
339
  def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
340
+ # Override max_tokens when the model declares a higher output ceiling
341
+ # in Providers::MODEL_MAX_OUTPUT. Without this, strong models (GLM-5.2,
342
+ # Kimi-K3, MiMo-V2.5) are throttled to the 16K global default.
343
+ model_for_limit = capability_model || model
344
+ model_limit = Providers.max_output_for(model_for_limit)
345
+ max_tokens = model_limit if model_limit
346
+
340
347
  # Apply cache_control markers to messages when caching is enabled.
341
348
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
342
349
  messages = apply_message_caching(messages) if caching_enabled
@@ -616,7 +623,7 @@ module Clacky
616
623
  when 401 then I18n.t("llm.error.invalid_api_key")
617
624
  when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
618
625
  when 404 then I18n.t("llm.error.endpoint_not_found")
619
- when 429 then I18n.t("llm.error.rate_limit_429")
626
+ when 429 then error_code == "quota_exceeded" ? I18n.t("llm.error.quota_exhausted") : I18n.t("llm.error.rate_limit_429")
620
627
  when 500..599 then I18n.t("llm.error.server_error", status: response.status)
621
628
  else extract_error_message(error_body, response.body)
622
629
  end
@@ -670,7 +677,11 @@ module Clacky
670
677
  raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
671
678
  when 404
672
679
  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")}"
680
+ when 429
681
+ if error_code == "quota_exceeded"
682
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.quota_exhausted")}", raw_message: error_message)
683
+ end
684
+ raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
674
685
  when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
675
686
  else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
676
687
  end
@@ -362,7 +362,7 @@ class ExtStudioExt < Clacky::ApiExtension
362
362
  post "/develop" do
363
363
  idea = presence(json_body["idea"])
364
364
  name = idea ? "扩展开发: #{idea[0, 40]}" : "扩展开发"
365
- sid = create_session(name: name, prompt: idea, profile: "ext-developer", source: :setup)
365
+ sid = create_session(name: name, prompt: idea, profile: "ext-developer")
366
366
  json(ok: true, session_id: sid)
367
367
  end
368
368