openclacky 0.9.35 → 0.9.37

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: ff82f5ba11ed8afdfb840119c249c078f0b10024e746230eedf093169f63ae55
4
- data.tar.gz: 0fe062fa3b73f168aeddde5a3a81a936a24faaa7be1b99329f8707bf52d89fbe
3
+ metadata.gz: 951665db04cf6c2a4f8ef9b5c0555f4df91b84af08f63d21097d97cbd64d44b2
4
+ data.tar.gz: 82457a522007f54ecd5fcc36fee8e79cf16a65996dd9b95d7a00fd0dcfbc2cac
5
5
  SHA512:
6
- metadata.gz: 1b7f42edce36076b5d467b6eefafdd02c40f381e25b9b010f0a32074ca51baea1a20545acdcde6a59467eb7a9b9b4fd930600f15a1858dd7aa54907ed855d391
7
- data.tar.gz: edf9c74ae0704914ed4012984ee8642294e097092123ef9c79521e985857068df615946d74b6b059c69dd0868683e6706db971ec393b6fca44f961bbd190b403
6
+ metadata.gz: 031b1031a702aca3a7cee36ea8ba23bc4982e95f8381e59d07ecb652432f6bc8a40e9a386e4a254d640f18f0088d9e18fa1f6434d92c038844f4821cef40a703
7
+ data.tar.gz: 5c5c36517efebb37a7a44d0531e0baadedb8600319682c39696a971771852019f2649386e15a3505d9ae32e86cf8c5818c64cf36943247220a7db6e0df24e994
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.37] - 2026-04-24
11
+
12
+ ### Fixed
13
+ - **Critical: pinned sessions could silently disappear from the sidebar** ("the pinned one isn't showing, and refreshing sometimes fixes it"). Root cause: the backend `list` endpoint only sorted by `created_at` and applied `limit` blindly, so a pinned session older than the first page's rows was cut off entirely — the frontend's `byPinnedAndTime` sort never saw it. "Refreshing sometimes worked" only if the pinned session happened to be recent enough to land in the first 20 rows. Fix: `SessionRegistry#list` now partitions results and **always returns ALL matching pinned sessions on the first page regardless of `limit`**, followed by up to `limit` non-pinned sessions. The `before` cursor applies only to the non-pinned section, so "load more" pages never re-send or duplicate pinned rows. `/api/sessions`'s `has_more` is now computed from non-pinned overflow only. Frontend `loadMore` cursor also excludes pinned rows so pagination jumps correctly. Regression specs cover: (a) an old pinned session still appears when `limit=3`, (b) multiple pinned sessions all fit on page one with `limit=1`, (c) pinned sessions never duplicate into `before`-cursor pages.
14
+ - **Critical: saving one model in Web UI Settings silently wiped other models' API keys.** The 0.9.36 index→id refactor (commit `b61e22e`) rebuilt each model hash from scratch on save (`"api_key" => api_key.to_s`), dropping the old `existing["api_key"] = api_key if api_key` guard. Combined with `/api/config` returning only `api_key_masked` (never `api_key`), every non-edited row in the POST body arrived with `api_key: undefined` — the backend then rewrote those rows' keys to `""`. Now `api_save_config` has three explicit cases for resolving `api_key`: (1) masked placeholder → keep stored key, (2) **missing/blank on an existing row → keep stored key (this fix)**, (3) otherwise use incoming value. Brand-new models (no `id`) still create with an empty key as before.
15
+ - **Critical: in-app upgrade no longer falsely reports failure.** The 0.9.36 upgrade flow shared a PTY helper (`run_shell`) with the new unified Terminal tool, which — by design — returns early with a `session_id` when command output stays quiet for 3 seconds. Long-running `gem install` operations routinely hit this during dependency resolution, causing the Web UI to show `✗ Upgrade failed.` even when the gem installed successfully. `run_shell` now delegates to a new `Terminal.run_sync` Ruby API that polls until the command truly completes, and `finish_upgrade` additionally re-checks the installed gem version as a defensive fallback.
16
+ - **Critical: "历史记录获取失败 (500: source sequence is illegal/malformed utf-8)" when opening a session.** When `file_reader` / `edit` / `grep` / `glob` encountered a file with non-UTF-8 bytes (e.g. GBK-encoded text or a Chinese Windows-exported CSV), the dirty bytes flowed through tool results into the agent history and session chunks on disk. Later, when `GET /api/sessions/:id/messages` replayed that history, `JSON.generate` would blow up on the invalid byte sequence and return 500. Now every IO source point scrubs invalid bytes to U+FFFD (`�`) at read time: `file_reader` (both content and directory entry names), `edit`, `grep` (`File.foreach` + context `readlines`), `glob` (`Dir.glob` path strings), `session_serializer` (chunk md replay), and `tool_executor` (diff preview). A defense-in-depth layer in `MessageHistory#append` / `#replace_all` recursively sanitizes every string that enters the message tree — so even a future tool that forgets to scrub cannot poison the session.
17
+
18
+ ### Added
19
+ - **New `Terminal.run_sync` internal API** for Ruby callers that need synchronous command capture (drop-in replacement for `Open3.capture2e`, but using the same PTY + login-shell + Security pipeline as the AI-facing tool).
20
+ - **DeepSeek V4 provider preset.** New `deepseekv4` entry in `Clacky::Providers` (positioned right after `openrouter`) with default model `deepseek-v4-pro` and models list `deepseek-v4-flash`, `deepseek-v4-pro`, plus the deprecated-aliases `deepseek-chat` / `deepseek-reasoner` (to be removed on 2026-07-24). Uses the OpenAI-compatible endpoint `https://api.deepseek.com`; for Anthropic-format usage, point `base_url` at `https://api.deepseek.com/anthropic` and switch `api` to `anthropic-messages`.
21
+ - **DeepSeek V4 pricing.** Added `deepseek-v4-flash` ($0.14 in / $0.28 out / $0.028 cache-hit per MTok) and `deepseek-v4-pro` ($1.74 in / $3.48 out / $0.145 cache-hit per MTok) to `Clacky::ModelPricing::PRICING_TABLE`. Legacy aliases `deepseek-chat` and `deepseek-reasoner` normalize to `deepseek-v4-flash`. DeepSeek has no separate cache-write charge, so cache writes are billed at the cache-miss (input) rate. Prices sourced from the official pricing page (USD per 1M tokens).
22
+
23
+ ## [0.9.36] - 2026-04-24
24
+
25
+ ### Fixed
26
+ - **Session deletion now works correctly**: fixed disk-based session deletion that was failing with proper error handling in the Web UI (C-9d1ea93)
27
+ - **Model switching improved**: better model ID validation and normalization when switching models in Web UI — handles various ID formats correctly (C-b61e22e)
28
+ - **Terminal tool word wrapping**: fixed terminal output word wrapping issues that could break long command outputs (C-5989d02)
29
+ - **Heartbeat mechanism stability**: improved async heartbeat logic in server mode for more reliable connection status tracking (C-5989d02)
30
+
31
+ ### Improved
32
+ - **UI polish**: removed session topbar clutter and added empty state messages for better first-time user experience (C-003d613)
33
+ - **Cleaner logging**: reduced noisy debug logs in skill manager for quieter operation (C-c27bbec)
34
+
10
35
  ## [0.9.35] - 2026-04-23
11
36
 
12
37
  ### Added
@@ -243,7 +243,12 @@ module Clacky
243
243
  end
244
244
  visited = visited.dup.add(canonical)
245
245
 
246
- raw = File.read(resolved)
246
+ # Scrub invalid UTF-8 bytes defensively — chunk files written before
247
+ # the 0.9.37 fix may contain poisoned bytes from file_reader results.
248
+ raw = File.read(resolved).then do |s|
249
+ s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
250
+ s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
251
+ end
247
252
 
248
253
  # Parse YAML front matter to get archived_at for synthetic timestamps
249
254
  archived_at = nil
@@ -68,15 +68,27 @@ module Clacky
68
68
  all_skills = all_skills.reject(&:invalid?)
69
69
  auto_invocable = all_skills.select(&:model_invocation_allowed?)
70
70
 
71
- # Enforce system prompt injection limit to control token usage
71
+ # Enforce system prompt injection limit to control token usage.
72
+ # Warn only when the set of dropped skills *changes* — this message
73
+ # is otherwise emitted once per agent turn (build_skill_context is
74
+ # called during every system prompt assembly) and floods the log.
72
75
  if auto_invocable.size > MAX_CONTEXT_SKILLS
73
- dropped = auto_invocable.size - MAX_CONTEXT_SKILLS
74
- Clacky::Logger.warn(
75
- "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
76
- "only injecting first #{MAX_CONTEXT_SKILLS} (#{dropped} dropped). " \
77
- "Remove unused skills to restore full visibility."
78
- )
79
- auto_invocable = auto_invocable.first(MAX_CONTEXT_SKILLS)
76
+ kept = auto_invocable.first(MAX_CONTEXT_SKILLS)
77
+ dropped = auto_invocable.drop(MAX_CONTEXT_SKILLS)
78
+ dropped_names = dropped.map(&:identifier)
79
+ signature = dropped_names.sort.join(",")
80
+
81
+ if @skill_limit_warned_signature != signature
82
+ @skill_limit_warned_signature = signature
83
+ Clacky::Logger.warn(
84
+ "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
85
+ "only injecting first #{MAX_CONTEXT_SKILLS} " \
86
+ "(#{dropped.size} dropped — will NOT be auto-discovered by the agent: " \
87
+ "#{dropped_names.join(", ")}). " \
88
+ "Remove unused skills to restore full visibility."
89
+ )
90
+ end
91
+ auto_invocable = kept
80
92
  end
81
93
 
82
94
  return "" if auto_invocable.empty?
@@ -358,6 +358,7 @@ module Clacky
358
358
  @ui&.show_diff("", new_content, max_lines: 50)
359
359
  else
360
360
  old_content = File.read(expanded_path)
361
+ old_content = old_content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") unless old_content.encoding == Encoding::UTF_8 && old_content.valid_encoding?
361
362
  @ui&.show_diff(old_content, new_content, max_lines: 50)
362
363
  end
363
364
  nil
data/lib/clacky/agent.rb CHANGED
@@ -131,14 +131,21 @@ module Clacky
131
131
  @hooks.add(event, &block)
132
132
  end
133
133
 
134
- # Switch to a different model by index
135
- # @param index [Integer] Model index (0-based)
134
+ # Switch this session to a different model, identified by its stable
135
+ # runtime id. Ids survive list reorders, additions, and field edits,
136
+ # which is why we no longer expose an index-based API.
137
+ # @param id [String] Model id (see AgentConfig#parse_models)
136
138
  # @return [Boolean] true if switched successfully, false otherwise
137
- def switch_model(index)
138
- # Switch config to the model by index
139
- return false unless @config.switch_model(index)
140
-
141
- # Re-create client for new model
139
+ def switch_model_by_id(id)
140
+ return false unless @config.switch_model_by_id(id)
141
+
142
+ rebuild_client_for_current_model!
143
+ true
144
+ end
145
+
146
+ # Rebuild the underlying Client (and dependent components) to pick up
147
+ # credentials/model name from the currently-selected model in @config.
148
+ private def rebuild_client_for_current_model!
142
149
  @client = Clacky::Client.new(
143
150
  @config.api_key,
144
151
  base_url: @config.base_url,
@@ -147,11 +154,9 @@ module Clacky
147
154
  )
148
155
  # Update message compressor with new client and model
149
156
  @message_compressor = MessageCompressor.new(@client, model: current_model)
150
-
157
+
151
158
  # Inject a new session context to notify the AI of the model switch
152
159
  inject_session_context
153
-
154
- true
155
160
  end
156
161
 
157
162
  # Change the working directory for this session
@@ -987,16 +992,16 @@ module Clacky
987
992
  if model == "lite"
988
993
  # Special keyword: use lite model if available, otherwise fall back to default
989
994
  lite_model = subagent_config.lite_model
990
- if lite_model
991
- model_index = subagent_config.models.index(lite_model)
992
- subagent_config.switch_model(model_index) if model_index
995
+ if lite_model && lite_model["id"]
996
+ subagent_config.switch_model_by_id(lite_model["id"])
993
997
  end
994
998
  # If no lite model, just use current (default) model
995
999
  else
996
- # Regular model name lookup
997
- model_index = subagent_config.model_names.index(model)
998
- if model_index
999
- subagent_config.switch_model(model_index)
1000
+ # Regular model name lookup — find the first model with a matching
1001
+ # name and switch by its stable id.
1002
+ target = subagent_config.models.find { |m| m["model"] == model }
1003
+ if target && target["id"]
1004
+ subagent_config.switch_model_by_id(target["id"])
1000
1005
  else
1001
1006
  raise AgentError, "Model '#{model}' not found in config. Available models: #{subagent_config.model_names.join(', ')}"
1002
1007
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require "fileutils"
5
+ require "securerandom"
5
6
 
6
7
  module Clacky
7
8
  # ClaudeCode environment variable compatibility layer
@@ -152,7 +153,7 @@ module Clacky
152
153
 
153
154
  attr_accessor :permission_mode, :max_tokens, :verbose,
154
155
  :enable_compression, :enable_prompt_caching,
155
- :models, :current_model_index,
156
+ :models, :current_model_index, :current_model_id,
156
157
  :memory_update_enabled, :skill_evolution
157
158
 
158
159
  def initialize(options = {})
@@ -165,7 +166,22 @@ module Clacky
165
166
 
166
167
  # Models configuration
167
168
  @models = options[:models] || []
169
+ # Ensure every model has a stable runtime id — this is the single
170
+ # invariant the rest of the system relies on. Regardless of how the
171
+ # config was built (load from yml, direct .new in tests, add_model,
172
+ # api_save_config), every model in @models will have an id.
173
+ @models.each { |m| m["id"] ||= SecureRandom.uuid }
174
+
168
175
  @current_model_index = options[:current_model_index] || 0
176
+ # Stable runtime id for the currently-selected model. Preferred over
177
+ # @current_model_index because ids are immune to list reordering,
178
+ # additions, and edits to model fields. Ids are injected at load time
179
+ # and never persisted to config.yml (backward compatible with old files).
180
+ # If caller didn't specify current_model_id, prefer the model marked
181
+ # as `type: default` (the documented convention), falling back to
182
+ # models[current_model_index] only if no default marker exists.
183
+ @current_model_id = options[:current_model_id] ||
184
+ (@models.find { |m| m["type"] == "default" } || @models[@current_model_index])&.dig("id")
169
185
 
170
186
  # Memory and skill evolution configuration
171
187
  @memory_update_enabled = options[:memory_update_enabled].nil? ? true : options[:memory_update_enabled]
@@ -238,11 +254,17 @@ module Clacky
238
254
  # The injected lite model is runtime-only (not persisted to config.yml)
239
255
  inject_provider_lite_model(models)
240
256
 
257
+ # Ensure every model has a stable runtime id — covers env-injected
258
+ # models (CLACKY_XXX, CLAUDE_XXX) that don't go through parse_models.
259
+ # Ids are NOT persisted to config.yml (see to_yaml).
260
+ models.each { |m| m["id"] ||= SecureRandom.uuid }
261
+
241
262
  # Find the index of the model marked as "default" (type: default)
242
263
  # Fall back to 0 if no model has type: default
243
264
  default_index = models.find_index { |m| m["type"] == "default" } || 0
265
+ default_id = models[default_index] && models[default_index]["id"]
244
266
 
245
- new(models: models, current_model_index: default_index)
267
+ new(models: models, current_model_index: default_index, current_model_id: default_id)
246
268
  end
247
269
 
248
270
  # Auto-inject a lite model entry if the default model's provider supports one
@@ -270,19 +292,35 @@ module Clacky
270
292
  "model" => lite_model_name,
271
293
  "anthropic_format" => default_model["anthropic_format"] || false,
272
294
  "type" => "lite",
273
- "auto_injected" => true # Mark as auto-injected (not saved to file)
295
+ "auto_injected" => true, # Mark as auto-injected (not saved to file)
296
+ "id" => SecureRandom.uuid # Runtime id for stable session references
274
297
  }
275
298
  end
276
299
 
277
- # Save configuration to file
278
- # Deep copy — models array contains mutable Hashes, so a shallow dup would
279
- # let the copy share the same Hash objects with the original, causing
280
- # Settings changes to silently mutate already-running session configs.
281
- # JSON round-trip is the cleanest approach since @models is pure JSON-able data.
300
+ # Create a per-session copy of this config.
301
+ #
302
+ # Plan B (shared models): we deliberately share the SAME @models array
303
+ # reference with all sessions (no deep clone). This is the key design
304
+ # decision that keeps session and global views in sync:
305
+ # - User adds a model in Settings → every live session sees it instantly.
306
+ # - User edits api_key/base_url → every live session's next API call
307
+ # picks up the new credentials (via current_model lookup).
308
+ # - Model ids are stable across edits, so each session's
309
+ # @current_model_id continues to resolve correctly.
310
+ #
311
+ # Per-session state that MUST stay isolated (permission_mode,
312
+ # @current_model_id, @current_model_index, fallback state) are scalar
313
+ # copies via `dup` and don't leak between sessions.
314
+ #
315
+ # Before Plan B, sessions held deep-copied @models — which silently
316
+ # diverged from the global list any time the user added/edited a model
317
+ # in Settings, producing bugs like "Failed to switch model" for newly
318
+ # added models on Windows and Linux. See http_server.rb#api_switch_session_model
319
+ # and http_server.rb#api_save_config for the companion logic.
282
320
  def deep_copy
283
- copy = dup
284
- copy.instance_variable_set(:@models, JSON.parse(JSON.generate(@models)))
285
- copy
321
+ # dup gives us a new AgentConfig with independent scalar ivars but
322
+ # the same @models reference — exactly what we want.
323
+ dup
286
324
  end
287
325
 
288
326
  def save(config_file = CONFIG_FILE)
@@ -295,8 +333,14 @@ module Clacky
295
333
  # Convert to YAML format (top-level array)
296
334
  # Auto-injected lite models (auto_injected: true) are excluded from persistence —
297
335
  # they are regenerated at load time from the provider preset.
336
+ # Runtime-only fields (id, auto_injected) are stripped before writing so
337
+ # config.yml remains backward compatible with users on older versions.
338
+ RUNTIME_ONLY_FIELDS = %w[id auto_injected].freeze
339
+
298
340
  def to_yaml
299
- persistable = @models.reject { |m| m["auto_injected"] }
341
+ persistable = @models.reject { |m| m["auto_injected"] }.map do |m|
342
+ m.reject { |k, _| RUNTIME_ONLY_FIELDS.include?(k) }
343
+ end
300
344
  YAML.dump(persistable)
301
345
  end
302
346
 
@@ -305,32 +349,80 @@ module Clacky
305
349
  !@models.empty? && !current_model.nil?
306
350
  end
307
351
 
308
- # Get current model configuration
309
- def current_model
310
- return nil if @models.empty?
311
- @models[@current_model_index]
312
- end
352
+ # NOTE: current_model is defined below (near the id-aware lookup path)
353
+ # — the earlier duplicate definition was removed. Ruby silently picks the
354
+ # last definition, but keeping only one avoids confusion.
313
355
 
314
356
  # Get model by index
315
357
  def get_model(index)
316
358
  @models[index]
317
359
  end
318
360
 
319
- # Switch to model by index
320
- # Updates the type: default to the selected model
321
- # Returns true if switched, false if index out of range
322
- def switch_model(index)
323
- return false if index < 0 || index >= @models.length
324
-
325
- # Remove type: default from all models
326
- @models.each { |m| m.delete("type") if m["type"] == "default" }
327
-
328
- # Set type: default on the selected model
329
- @models[index]["type"] = "default"
330
-
331
- # Update current_model_index for backward compatibility
361
+ # Switch the current session to a specific model, identified by its
362
+ # stable runtime id.
363
+ #
364
+ # This is a **per-session** operation:
365
+ # - Updates this AgentConfig's `@current_model_id` (primary truth)
366
+ # - Updates `@current_model_index` for back-compat observers
367
+ # - Does NOT mutate the shared `@models` array's `type: "default"`
368
+ # marker. The "default model" is a global setting (initial model
369
+ # for new sessions) and is only changed via the Settings UI
370
+ # "save config" flow (`api_save_config`).
371
+ #
372
+ # @param id [String] the model's runtime id (see parse_models)
373
+ # @return [Boolean] true if switched, false if id not found
374
+ def switch_model_by_id(id)
375
+ return false if id.nil? || id.to_s.empty?
376
+
377
+ index = @models.find_index { |m| m["id"] == id }
378
+ return false if index.nil?
379
+
380
+ @current_model_id = id
332
381
  @current_model_index = index
333
-
382
+
383
+ true
384
+ end
385
+
386
+ # Set the **global** default model marker (`type: "default"`).
387
+ #
388
+ # This is separate from `switch_model_by_id`:
389
+ # - `switch_model_by_id` only changes this session's current model.
390
+ # - `set_default_model_by_id` mutates the shared `@models` array by
391
+ # moving the `type: "default"` marker to the given model.
392
+ #
393
+ # Use cases:
394
+ # - CLI (single-session): when the user picks a model, we both switch
395
+ # this session AND update the global default so future CLI launches
396
+ # use the same model. Caller must `save` to persist.
397
+ # - Web UI Settings save flow: also uses this (via payload).
398
+ #
399
+ # Do NOT call from per-session model switching in multi-session contexts
400
+ # (Web UI session-level switch), since it would leak into other sessions
401
+ # and change what new sessions start with.
402
+ #
403
+ # Only one model may carry `type: "default"` at a time — this method
404
+ # clears the marker on any other model that had it.
405
+ #
406
+ # Note: if the target model currently has `type: "lite"`, this method
407
+ # will overwrite it with `"default"`. That matches the existing
408
+ # single-slot `type` field semantics in the codebase.
409
+ #
410
+ # @param id [String] the model's runtime id
411
+ # @return [Boolean] true if marker was moved, false if id not found
412
+ def set_default_model_by_id(id)
413
+ return false if id.nil? || id.to_s.empty?
414
+
415
+ target = @models.find { |m| m["id"] == id }
416
+ return false if target.nil?
417
+
418
+ # Clear existing default marker(s) — there should only be one, but
419
+ # be defensive in case of corrupted config.
420
+ @models.each do |m|
421
+ next if m["id"] == id
422
+ m.delete("type") if m["type"] == "default"
423
+ end
424
+
425
+ target["type"] = "default"
334
426
  true
335
427
  end
336
428
 
@@ -385,6 +477,7 @@ module Clacky
385
477
  # Add a new model configuration
386
478
  def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
387
479
  @models << {
480
+ "id" => SecureRandom.uuid,
388
481
  "api_key" => api_key,
389
482
  "base_url" => base_url,
390
483
  "model" => model,
@@ -486,15 +579,35 @@ module Clacky
486
579
  end
487
580
  end
488
581
 
489
- # Get current model configuration
490
- # Looks for type: default first, falls back to current_model_index
582
+ # Get current model configuration.
583
+ #
584
+ # Resolution order:
585
+ # 1. @current_model_id (primary source of truth — stable across list edits)
586
+ # 2. type: default (for config.yml that sets a default explicitly)
587
+ # 3. @current_model_index (back-compat for very old code paths)
491
588
  def current_model
492
589
  return nil if @models.empty?
590
+
591
+ if @current_model_id
592
+ m = @models.find { |mm| mm["id"] == @current_model_id }
593
+ return m if m
594
+ # id no longer exists (model was deleted). Fall through to other
595
+ # resolution strategies below, and clear the stale id.
596
+ @current_model_id = nil
597
+ end
598
+
493
599
  default_model = find_model_by_type("default")
494
- return default_model if default_model
495
-
600
+ if default_model
601
+ # Opportunistically re-anchor to this default's id so subsequent
602
+ # lookups are O(1) and survive list reordering.
603
+ @current_model_id = default_model["id"]
604
+ return default_model
605
+ end
606
+
496
607
  # Fallback to index-based for backward compatibility
497
- @models[@current_model_index]
608
+ m = @models[@current_model_index]
609
+ @current_model_id = m["id"] if m
610
+ m
498
611
  end
499
612
 
500
613
  # Set a model's type (default or lite)
@@ -528,14 +641,20 @@ module Clacky
528
641
  # Don't allow removing the last model
529
642
  return false if @models.length <= 1
530
643
  return false if index < 0 || index >= @models.length
531
-
532
- @models.delete_at(index)
533
-
644
+
645
+ removed = @models.delete_at(index)
646
+
534
647
  # Adjust current_model_index if necessary
535
648
  if @current_model_index >= @models.length
536
649
  @current_model_index = @models.length - 1
537
650
  end
538
-
651
+
652
+ # If the removed model was the current one, clear @current_model_id.
653
+ # current_model will then fall back to type: default / current_model_index.
654
+ if removed && @current_model_id == removed["id"]
655
+ @current_model_id = nil
656
+ end
657
+
539
658
  true
540
659
  end
541
660
 
@@ -615,6 +734,13 @@ module Clacky
615
734
  }
616
735
  end
617
736
 
737
+ # Inject a runtime-only stable id for each model. Ids are NOT written
738
+ # back to config.yml (see `to_yaml`) so this is fully backward
739
+ # compatible — old yml files without ids just get fresh ids on load.
740
+ # The id is the source of truth for session→model identity and is
741
+ # immune to list reordering, additions, and field edits (api_key, etc).
742
+ models.each { |m| m["id"] ||= SecureRandom.uuid }
743
+
618
744
  models
619
745
  end
620
746
  end
data/lib/clacky/cli.rb CHANGED
@@ -271,26 +271,45 @@ module Clacky
271
271
  end
272
272
 
273
273
  if brand.heartbeat_due?
274
- Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, sending...")
275
- result = brand.heartbeat!
276
- if result[:success]
277
- Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat OK")
278
- else
279
- Clacky::Logger.warn("[Brand] check_brand_license_cli: heartbeat failed #{result[:message]} grace_exceeded=#{brand.grace_period_exceeded?}")
280
- unless result[:success]
281
- if brand.grace_period_exceeded?
282
- say ""
283
- say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
284
- say "License has been offline for more than 3 days. Please check your connection.", :yellow
285
- say ""
274
+ # Fire-and-forget heartbeat in a background thread.
275
+ #
276
+ # Rationale: a slow/unreachable license server would otherwise block
277
+ # CLI startup for up to ~92s (2 hosts × 2 attempts × 23s timeout)
278
+ # before the user sees the prompt. Heartbeat is "best-effort" by
279
+ # design its only job is to refresh `last_heartbeat` / `expires_at`
280
+ # on disk for the next run's grace-period calculation. Missing a
281
+ # single heartbeat is harmless; the next launch will try again.
282
+ #
283
+ # Consequence: if this run was going to trigger the
284
+ # grace_period_exceeded warning, the user will see it on the *next*
285
+ # launch instead of this one. Acceptable trade-off.
286
+ Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
287
+ Thread.new do
288
+ begin
289
+ result = brand.heartbeat!
290
+ if result[:success]
291
+ Clacky::Logger.info("[Brand] async heartbeat OK")
286
292
  else
287
- say "(License heartbeat failed - will retry tomorrow.)", :cyan
293
+ Clacky::Logger.warn("[Brand] async heartbeat failed #{result[:message]}")
288
294
  end
295
+ rescue StandardError => e
296
+ Clacky::Logger.warn("[Brand] async heartbeat raised: #{e.class}: #{e.message}")
289
297
  end
290
298
  end
291
299
  else
292
300
  Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
293
301
  end
302
+
303
+ # Surface the grace-period warning based on *already-persisted* state
304
+ # (computed from last_heartbeat on disk). This works whether the
305
+ # previous run's heartbeat succeeded, failed, or was interrupted —
306
+ # grace_period_exceeded? reads last_heartbeat, not this run's result.
307
+ if brand.grace_period_exceeded?
308
+ say ""
309
+ say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
310
+ say "License has been offline for more than 3 days. Please check your connection.", :yellow
311
+ say ""
312
+ end
294
313
  end
295
314
 
296
315
  # Interactive license key prompt using tty-prompt.
@@ -34,7 +34,7 @@ module Clacky
34
34
  if message[:role] == "user"
35
35
  drop_dangling_tool_calls!
36
36
  end
37
- @messages << message
37
+ @messages << deep_sanitize_utf8(message)
38
38
  self
39
39
  end
40
40
 
@@ -53,7 +53,7 @@ module Clacky
53
53
 
54
54
  # Replace the entire message list (used by compression rebuild).
55
55
  def replace_all(new_messages)
56
- @messages = new_messages.dup
56
+ @messages = new_messages.map { |m| deep_sanitize_utf8(m) }
57
57
  self
58
58
  end
59
59
 
@@ -229,5 +229,46 @@ module Clacky
229
229
  private def strip_internal_fields(message)
230
230
  message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
231
231
  end
232
+
233
+ # Defense-in-depth: recursively scrub invalid UTF-8 bytes from every String
234
+ # stored in the message tree. Even if a tool forgets to scrub its output,
235
+ # nothing poisoned will ever reach session persistence or JSON.generate.
236
+ #
237
+ # Fast path: if the tree contains only valid UTF-8 strings, the original
238
+ # object is returned unchanged — preserving object identity for callers
239
+ # that rely on `equal?` (e.g. rollback_before).
240
+ # Slow path: any invalid byte triggers a rebuild with scrubbed strings
241
+ # (invalid bytes → U+FFFD).
242
+ private def deep_sanitize_utf8(obj)
243
+ case obj
244
+ when String
245
+ return obj if obj.encoding == Encoding::UTF_8 && obj.valid_encoding?
246
+ obj.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
247
+ when Hash
248
+ return obj unless contains_dirty_utf8?(obj)
249
+ obj.transform_values { |v| deep_sanitize_utf8(v) }
250
+ when Array
251
+ return obj unless contains_dirty_utf8?(obj)
252
+ obj.map { |v| deep_sanitize_utf8(v) }
253
+ else
254
+ obj
255
+ end
256
+ end
257
+
258
+ # Cheap recursive check: does this subtree contain any invalid-UTF-8 string?
259
+ # Short-circuits on first offender. Keeps the common case (all valid UTF-8)
260
+ # allocation-free.
261
+ private def contains_dirty_utf8?(obj)
262
+ case obj
263
+ when String
264
+ !(obj.encoding == Encoding::UTF_8 && obj.valid_encoding?)
265
+ when Hash
266
+ obj.any? { |_, v| contains_dirty_utf8?(v) }
267
+ when Array
268
+ obj.any? { |v| contains_dirty_utf8?(v) }
269
+ else
270
+ false
271
+ end
272
+ end
232
273
  end
233
274
  end
@@ -41,6 +41,27 @@ module Clacky
41
41
  "website_url" => "https://openrouter.ai/keys"
42
42
  }.freeze,
43
43
 
44
+ "deepseekv4" => {
45
+ "name" => "DeepSeek V4",
46
+ # DeepSeek API is compatible with both OpenAI and Anthropic formats.
47
+ # We use the OpenAI-compatible endpoint here (matches kimi/minimax/glm style).
48
+ # For Anthropic-format usage, point base_url at https://api.deepseek.com/anthropic
49
+ # and change "api" to "anthropic-messages".
50
+ "base_url" => "https://api.deepseek.com",
51
+ "api" => "openai-completions",
52
+ "default_model" => "deepseek-v4-pro",
53
+ # Note: deepseek-chat and deepseek-reasoner are legacy aliases being
54
+ # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
55
+ # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
56
+ "models" => [
57
+ "deepseek-v4-flash",
58
+ "deepseek-v4-pro",
59
+ "deepseek-chat",
60
+ "deepseek-reasoner"
61
+ ],
62
+ "website_url" => "https://platform.deepseek.com/api_keys"
63
+ }.freeze,
64
+
44
65
  "minimax" => {
45
66
  "name" => "Minimax",
46
67
  "base_url" => "https://api.minimaxi.com/v1",