openclacky 0.9.35 → 0.9.36

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: 8a7f74cba226bc2332676ed9f0a72c27f3fb6b45bafacd60198f7ec9b59a84c7
4
+ data.tar.gz: 7396d2258a38e203a5ffe217f0409fb101487bdb74dad8f224649a09b8adb775
5
5
  SHA512:
6
- metadata.gz: 1b7f42edce36076b5d467b6eefafdd02c40f381e25b9b010f0a32074ca51baea1a20545acdcde6a59467eb7a9b9b4fd930600f15a1858dd7aa54907ed855d391
7
- data.tar.gz: edf9c74ae0704914ed4012984ee8642294e097092123ef9c79521e985857068df615946d74b6b059c69dd0868683e6706db971ec393b6fca44f961bbd190b403
6
+ metadata.gz: 7f10c234d4a22d825e3623bff7e0a0faf55af4cea34dc15a939fbde3e6e3792bf1a11cd97d68e0c6890dd37c011814019cfed7dea70c724fcb2fc11b49cff15a
7
+ data.tar.gz: 12dcd062214b42191f33e4b2e09ec62f24cb9f91206451ff23861672a2cc8b680d89e5498cf0da6c53b74d4da2933bd53863666cee608539edb398f016226df5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.36] - 2026-04-24
11
+
12
+ ### Fixed
13
+ - **Session deletion now works correctly**: fixed disk-based session deletion that was failing with proper error handling in the Web UI (C-9d1ea93)
14
+ - **Model switching improved**: better model ID validation and normalization when switching models in Web UI — handles various ID formats correctly (C-b61e22e)
15
+ - **Terminal tool word wrapping**: fixed terminal output word wrapping issues that could break long command outputs (C-5989d02)
16
+ - **Heartbeat mechanism stability**: improved async heartbeat logic in server mode for more reliable connection status tracking (C-5989d02)
17
+
18
+ ### Improved
19
+ - **UI polish**: removed session topbar clutter and added empty state messages for better first-time user experience (C-003d613)
20
+ - **Cleaner logging**: reduced noisy debug logs in skill manager for quieter operation (C-c27bbec)
21
+
10
22
  ## [0.9.35] - 2026-04-23
11
23
 
12
24
  ### Added
@@ -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?
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.