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.
@@ -606,6 +606,52 @@ module Clacky
606
606
 
607
607
  # ── Brand API ─────────────────────────────────────────────────────────────
608
608
 
609
+ # Process-wide mutex guarding heartbeat trigger state.
610
+ # Used by #trigger_async_heartbeat! to ensure only one heartbeat Thread is
611
+ # in flight at a time, no matter how many concurrent /api/brand/status
612
+ # requests arrive from the Web UI poller.
613
+ BRAND_HEARTBEAT_MUTEX = Mutex.new
614
+ # Tracks whether a heartbeat Thread is currently running.
615
+ @@brand_heartbeat_inflight = false
616
+
617
+ # Fire a heartbeat in a background Thread without blocking the caller.
618
+ #
619
+ # Contract:
620
+ # * Only one heartbeat Thread may be running at any moment across the
621
+ # whole process. If one is already in flight, this call is a no-op.
622
+ # * The caller never waits: it returns immediately after (at most)
623
+ # spawning the Thread.
624
+ # * The Thread rescues everything so a network failure cannot kill the
625
+ # server or leak an exception through the web stack.
626
+ def trigger_async_heartbeat!
627
+ BRAND_HEARTBEAT_MUTEX.synchronize do
628
+ if @@brand_heartbeat_inflight
629
+ Clacky::Logger.debug("[Brand] heartbeat already in flight, skipping")
630
+ return
631
+ end
632
+ @@brand_heartbeat_inflight = true
633
+ end
634
+
635
+ Thread.new do
636
+ Clacky::Logger.info("[Brand] async heartbeat starting...")
637
+ begin
638
+ brand = Clacky::BrandConfig.load
639
+ result = brand.heartbeat!
640
+ if result[:success]
641
+ Clacky::Logger.info("[Brand] async heartbeat OK")
642
+ else
643
+ Clacky::Logger.warn("[Brand] async heartbeat failed — #{result[:message]}")
644
+ end
645
+ rescue StandardError => e
646
+ Clacky::Logger.warn("[Brand] async heartbeat raised: #{e.class}: #{e.message}")
647
+ ensure
648
+ BRAND_HEARTBEAT_MUTEX.synchronize do
649
+ @@brand_heartbeat_inflight = false
650
+ end
651
+ end
652
+ end
653
+ end
654
+
609
655
  # GET /api/brand/status
610
656
  # Returns whether brand activation is needed.
611
657
  # Mirrors the onboard/status pattern so the frontend can gate on it.
@@ -634,17 +680,15 @@ module Clacky
634
680
  return
635
681
  end
636
682
 
637
- # Send heartbeat if interval has elapsed (once per day)
683
+ # Send heartbeat asynchronously if interval has elapsed (once per day).
684
+ #
685
+ # We must NOT block this HTTP response on the heartbeat call: a slow or
686
+ # unreachable license server would otherwise stall the Web UI's first
687
+ # paint for up to ~92s (2 hosts × 2 attempts × 23s timeout). The fresh
688
+ # expires_at / last_heartbeat will be picked up on the next /api/brand/status
689
+ # poll, which is sufficient for a once-per-day check.
638
690
  if brand.heartbeat_due?
639
- Clacky::Logger.info("[Brand] api_brand_status: heartbeat due, sending...")
640
- result = brand.heartbeat!
641
- if result[:success]
642
- Clacky::Logger.info("[Brand] api_brand_status: heartbeat OK")
643
- else
644
- Clacky::Logger.warn("[Brand] api_brand_status: heartbeat failed — #{result[:message]}")
645
- end
646
- # Reload after heartbeat to pick up updated expires_at / last_heartbeat
647
- brand = Clacky::BrandConfig.load
691
+ trigger_async_heartbeat!
648
692
  else
649
693
  Clacky::Logger.debug("[Brand] api_brand_status: heartbeat not due yet")
650
694
  end
@@ -1828,6 +1872,7 @@ module Clacky
1828
1872
  def api_get_config(res)
1829
1873
  models = @agent_config.models.map.with_index do |m, i|
1830
1874
  {
1875
+ id: m["id"], # Stable runtime id — use this for switching
1831
1876
  index: i,
1832
1877
  model: m["model"],
1833
1878
  base_url: m["base_url"],
@@ -1838,12 +1883,19 @@ module Clacky
1838
1883
  end
1839
1884
  # Filter out auto-injected models (like lite) from UI display
1840
1885
  models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
1841
- json_response(res, 200, { models: models, current_index: @agent_config.current_model_index })
1886
+ json_response(res, 200, {
1887
+ models: models,
1888
+ current_index: @agent_config.current_model_index,
1889
+ current_id: @agent_config.current_model&.dig("id")
1890
+ })
1842
1891
  end
1843
1892
 
1844
1893
  # POST /api/config — save updated model list
1845
- # Body: { models: [ { index, model, base_url, api_key, anthropic_format, type } ] }
1846
- # api_key may be masked ("sk-ab12****...5678") keep existing key in that case
1894
+ # Body: { models: [ { id?, index, model, base_url, api_key, anthropic_format, type } ] }
1895
+ # - id may be present for existing models (preserved) or absent for newly added
1896
+ # models (a new id is generated). Ids are runtime-only and stripped before
1897
+ # writing to config.yml (see AgentConfig#to_yaml).
1898
+ # - api_key may be masked ("sk-ab12****...5678") — keep existing key in that case
1847
1899
  def api_save_config(req, res)
1848
1900
  body = parse_json_body(req)
1849
1901
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
@@ -1851,8 +1903,18 @@ module Clacky
1851
1903
  incoming = body["models"]
1852
1904
  return json_response(res, 400, { error: "models array required" }) unless incoming.is_a?(Array)
1853
1905
 
1854
- incoming.each_with_index do |m, i|
1855
- existing = @agent_config.models[i]
1906
+ # Build a quick id→existing-model lookup. Ids are the single source
1907
+ # of identity for models across save/reload cycles — no index-based
1908
+ # fallback (ids are stable; indexes are not). Live sessions' stored
1909
+ # @current_model_id stays valid as long as the id is still present
1910
+ # in the list after save.
1911
+ existing_by_id = {}
1912
+ @agent_config.models.each { |m| existing_by_id[m["id"]] = m if m["id"] }
1913
+
1914
+ new_models = incoming.map do |m|
1915
+ # Lookup by id only. No id means a brand-new model — we mint one.
1916
+ existing = m["id"] && existing_by_id[m["id"]]
1917
+
1856
1918
  # Resolve api_key: if masked placeholder, keep the stored key
1857
1919
  api_key = if m["api_key"].to_s.include?("****")
1858
1920
  existing&.dig("api_key")
@@ -1860,26 +1922,26 @@ module Clacky
1860
1922
  m["api_key"]
1861
1923
  end
1862
1924
 
1863
- if existing
1864
- existing["model"] = m["model"] if m.key?("model")
1865
- existing["base_url"] = m["base_url"] if m.key?("base_url")
1866
- existing["api_key"] = api_key if api_key
1867
- existing["anthropic_format"] = m["anthropic_format"] if m.key?("anthropic_format")
1868
- existing["type"] = m["type"] if m.key?("type")
1869
- else
1870
- @agent_config.add_model(
1871
- model: m["model"].to_s,
1872
- api_key: api_key.to_s,
1873
- base_url: m["base_url"].to_s,
1874
- anthropic_format: m["anthropic_format"] || false,
1875
- type: m["type"]
1876
- )
1877
- end
1925
+ {
1926
+ "id" => (existing && existing["id"]) || SecureRandom.uuid,
1927
+ "model" => m["model"].to_s,
1928
+ "base_url" => m["base_url"].to_s,
1929
+ "api_key" => api_key.to_s,
1930
+ "anthropic_format" => m["anthropic_format"] || false,
1931
+ "type" => m["type"]
1932
+ }.tap { |h| h.delete("type") if h["type"].nil? || h["type"].to_s.empty? }
1878
1933
  end
1879
1934
 
1880
- # Remove models that are no longer present (trim to incoming length)
1881
- while @agent_config.models.length > incoming.length
1882
- @agent_config.models.pop
1935
+ # Replace @models in place do NOT reassign the array, because every
1936
+ # live session holds a reference to the same array (Plan B shared
1937
+ # models). `replace` mutates in place so all sessions see the new list.
1938
+ @agent_config.models.replace(new_models)
1939
+
1940
+ # Re-anchor current_model_index to the model still holding type: default
1941
+ if (new_default_idx = new_models.find_index { |m| m["type"] == "default" })
1942
+ @agent_config.current_model_index = new_default_idx
1943
+ elsif @agent_config.current_model_index >= new_models.length
1944
+ @agent_config.current_model_index = [new_models.length - 1, 0].max
1883
1945
  end
1884
1946
 
1885
1947
  @agent_config.save
@@ -2004,36 +2066,38 @@ module Clacky
2004
2066
 
2005
2067
  def api_switch_session_model(session_id, req, res)
2006
2068
  body = parse_json_body(req)
2007
- new_model_name = body["model"].to_s.strip
2069
+ model_id = body["model_id"].to_s.strip
2008
2070
 
2009
- return json_response(res, 400, { error: "model is required" }) if new_model_name.empty?
2071
+ return json_response(res, 400, { error: "model_id is required" }) if model_id.empty?
2010
2072
  return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2011
2073
 
2012
2074
  agent = nil
2013
2075
  @registry.with_session(session_id) { |s| agent = s[:agent] }
2014
-
2015
- # Find the model configuration index by model name (use global config)
2016
- model_index = @agent_config.models.find_index { |m| m["model"] == new_model_name }
2017
-
2018
- if model_index.nil?
2019
- return json_response(res, 400, { error: "Model '#{new_model_name}' not found in configuration" })
2076
+
2077
+ # With Plan B (shared @models reference), every session's AgentConfig
2078
+ # points at the same @models array as the global @agent_config. So
2079
+ # resolving the model by stable id here and in agent.switch_model_by_id
2080
+ # will always agree — no more index divergence after add/delete.
2081
+ target_model = @agent_config.models.find { |m| m["id"] == model_id }
2082
+ if target_model.nil?
2083
+ return json_response(res, 400, { error: "Model not found in configuration" })
2020
2084
  end
2021
-
2022
- # Switch to the model by index (unified interface with CLI)
2023
- # This handles: config.switch_model + client rebuild + message_compressor rebuild
2024
- success = agent.switch_model(model_index)
2025
-
2085
+
2086
+ # Switch to the model by id (unified interface with CLI)
2087
+ # Handles: config.switch_model_by_id + client rebuild + message_compressor rebuild
2088
+ success = agent.switch_model_by_id(model_id)
2089
+
2026
2090
  unless success
2027
2091
  return json_response(res, 500, { error: "Failed to switch model" })
2028
2092
  end
2029
-
2093
+
2030
2094
  # Persist the change (saves to session file, NOT global config.yml)
2031
2095
  @session_manager.save(agent.to_session_data)
2032
-
2096
+
2033
2097
  # Broadcast update to all clients
2034
2098
  broadcast_session_update(session_id)
2035
-
2036
- json_response(res, 200, { ok: true, model: new_model_name })
2099
+
2100
+ json_response(res, 200, { ok: true, model_id: model_id, model: target_model["model"] })
2037
2101
  rescue => e
2038
2102
  json_response(res, 500, { error: e.message })
2039
2103
  end
@@ -2071,16 +2135,34 @@ module Clacky
2071
2135
  end
2072
2136
 
2073
2137
  def api_delete_session(session_id, res)
2074
- if @registry.delete(session_id)
2075
- # Also remove the persisted session file from disk
2076
- @session_manager.delete(session_id)
2077
- # Notify connected clients the session is gone
2078
- broadcast(session_id, { type: "session_deleted", session_id: session_id })
2079
- unsubscribe_all(session_id)
2080
- json_response(res, 200, { ok: true })
2081
- else
2082
- json_response(res, 404, { error: "Session not found" })
2138
+ # A session exists if it's either in the runtime registry OR on disk.
2139
+ # Old sessions that were never restored into memory this server run
2140
+ # (e.g. shown via "load more" in the WebUI list) are disk-only — we
2141
+ # must still be able to delete them. Previously this endpoint only
2142
+ # consulted @registry and returned 404 for disk-only sessions,
2143
+ # causing the "can't delete old sessions" bug.
2144
+ in_registry = @registry.exist?(session_id)
2145
+ on_disk = !@session_manager.load(session_id).nil?
2146
+
2147
+ unless in_registry || on_disk
2148
+ return json_response(res, 404, { error: "Session not found" })
2083
2149
  end
2150
+
2151
+ # Registry delete is best-effort — only meaningful when the session
2152
+ # is actually live (cancels idle timer, interrupts the agent thread).
2153
+ # For disk-only sessions this is a no-op and returns false, which is
2154
+ # fine and no longer blocks the disk cleanup below.
2155
+ @registry.delete(session_id) if in_registry
2156
+
2157
+ # Always physically remove the persisted session file (+ chunks).
2158
+ @session_manager.delete(session_id) if on_disk
2159
+
2160
+ # Notify any still-connected clients (mainly matters when the
2161
+ # session was live, but harmless otherwise).
2162
+ broadcast(session_id, { type: "session_deleted", session_id: session_id })
2163
+ unsubscribe_all(session_id)
2164
+
2165
+ json_response(res, 200, { ok: true })
2084
2166
  end
2085
2167
 
2086
2168
  # Export a session bundle as a .zip download containing:
@@ -274,7 +274,7 @@ module Clacky
274
274
  raw = read_log_slice(session.log_file, start_offset, new_offset)
275
275
  cleaned = OutputCleaner.clean(raw)
276
276
  cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
277
- cleaned = strip_command_echo(cleaned)
277
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
278
278
  truncated = false
279
279
  if cleaned.bytesize > MAX_LLM_OUTPUT_CHARS
280
280
  cleaned = cleaned.byteslice(0, MAX_LLM_OUTPUT_CHARS) + "\n...[output truncated]"
@@ -336,23 +336,97 @@ module Clacky
336
336
  # The shell may echo the wrapper line we injected (`{ USER_CMD; }; ...;
337
337
  # printf "__CLACKY_DONE_..."`) before running it. When stty -echo is
338
338
  # honoured (bash/fresh pty) this is a no-op; when it isn't (zsh ZLE
339
- # sometimes re-enables echo on reuse) we strip the wrapper echo.
339
+ # sometimes re-enables echo on reuse, or the user sent input to a
340
+ # running session) we strip the wrapper echo wherever it appears.
340
341
  #
341
- # Note: when the PTY is in cooked mode and echoes the wrapper, the
342
- # terminal *interprets* the backslash-n escape pairs inside the
343
- # double-quoted printf format, so the wrapper echo spans multiple
344
- # real \n lines not just two. We match lazily up to the closing
345
- # `"$__clacky_ec"` quote so we catch the entire echoed wrapper.
346
- private def strip_command_echo(text)
342
+ # Observed variants of the echoed wrapper:
343
+ #
344
+ # 1) Multi-line, starting the buffer (PTY in cooked mode, expanded
345
+ # \n escapes inside printf's double-quoted format string):
346
+ # { USER_CMD
347
+ # }; __clacky_ec=$?; printf "
348
+ # __CLACKY_DONE_<token>_%s__
349
+ # " "$__clacky_ec"
350
+ #
351
+ # 2) Single-line / partially-truncated (PTY width wrap or partial
352
+ # char drop ate the leading `{` or first chars of the command):
353
+ # ails runner foo.rb ... }; __clacky_ec=$?; printf " __CLACKY_DONE_<token>_%s__ " "$__clacky_ec"
354
+ #
355
+ # 3) Embedded mid-stream when re-echoed (e.g. after session re-use
356
+ # or after a user input: call landed in a shell that re-enabled
357
+ # echo). Same shape as (1) or (2) but not anchored to the start.
358
+ #
359
+ # We handle all three by running two passes:
360
+ # * an anchored multi-line strip (keeps the legacy behaviour and is
361
+ # cheapest when stty -echo silently failed);
362
+ # * a token-aware global strip that removes any remaining echoed
363
+ # wrapper fragment anywhere in the buffer. The token makes this
364
+ # safe: the real completion marker was already removed via
365
+ # session.marker_regex above, so any surviving occurrence of
366
+ # __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
367
+ private def strip_command_echo(text, marker_token: nil)
347
368
  return text if text.nil? || text.empty?
348
- # Match the whole echoed wrapper, however many lines the terminal
349
- # expanded its \n escapes into:
350
- # { USER_CMD
351
- # }; __clacky_ec=$?; printf "
352
- # __CLACKY_DONE_<token>_%s__
353
- # " "$__clacky_ec"
354
- # Anchored at the start; non-greedy across newlines via /m.
369
+
370
+ # Pass 1: anchored strip — the full wrapper echoed at the start,
371
+ # possibly spanning multiple real newlines.
355
372
  text = text.sub(/\A\{.*?"\$__clacky_ec"\s*\n?/m, "")
373
+
374
+ # Pass 2: token-aware global strip — remove any leftover wrapper
375
+ # echo fragment, wherever it sits. Requires the session token so
376
+ # we never touch unrelated user output that happens to mention
377
+ # `__clacky_ec`.
378
+ if marker_token && !marker_token.empty?
379
+ token_re = Regexp.escape(marker_token)
380
+
381
+ # 2a. Multi-line shape: walk back from __CLACKY_DONE_<token> to
382
+ # the opening `{` of the wrapper (start of line or start of
383
+ # buffer) and forward to the closing `"$__clacky_ec"`.
384
+ text = text.gsub(
385
+ /(?:^|(?<=\n))\{[^\n]*\n(?:[^\n]*\n)*?[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*\n[^\n]*"\$__clacky_ec"[^\n]*\n?/,
386
+ ""
387
+ )
388
+
389
+ # 2b. Single-line shape: everything collapsed onto one line.
390
+ # Strip from the wrapper's `}; __clacky_ec=$?` pivot (or the
391
+ # opening `{` if still present on that line) through the end of
392
+ # the printf invocation (`"$__clacky_ec"`).
393
+ text = text.gsub(
394
+ /[^\n]*\}; *__clacky_ec=\$\?; *printf[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*"\$__clacky_ec"[^\n]*\n?/,
395
+ ""
396
+ )
397
+
398
+ # 2c. Last-resort: a bare marker-format fragment on its own,
399
+ # without the `}; printf ...` prefix (e.g. terminal wrapped the
400
+ # echo such that only the tail survived). Drop lines that
401
+ # contain the literal `__CLACKY_DONE_<token>_%s__` format —
402
+ # the real marker has `\d+` in place of `%s` so this only hits
403
+ # echoed wrappers.
404
+ text = text.gsub(/^.*__CLACKY_DONE_#{token_re}_%s__.*\n?/, "")
405
+ end
406
+
407
+ # Pass 3: token-INDEPENDENT fingerprint strip — PTY width-wrap
408
+ # can chop the `__CLACKY_DONE_<token>_%s__` format string out of
409
+ # printf entirely, leaving e.g. `}; __clacky_ec=$?; printf " " "$__clacky_ec"`.
410
+ # None of the token-aware patterns above catch that. The pair
411
+ # `}; __clacky_ec=$?` (opening pivot) and `"$__clacky_ec"` (printf
412
+ # tail) are our wrapper's unique fingerprints — `__clacky_ec` is a
413
+ # private double-underscore var name that user code effectively
414
+ # never emits — so we strip anything between them (non-greedy,
415
+ # multiline-aware) to also handle width-wrap that inserted
416
+ # real \n breaks inside the echo.
417
+ text = text.gsub(
418
+ /[^\n]*\}; *__clacky_ec=\$\?.*?"\$__clacky_ec"[^\n]*\n?/m,
419
+ ""
420
+ )
421
+
422
+ # Pass 4: bare pivot with no printf tail at all (extreme
423
+ # truncation cut off everything after `__clacky_ec=$?`). Still a
424
+ # reliable fingerprint thanks to the `__clacky_ec` var name.
425
+ text = text.gsub(
426
+ /[^\n]*\}; *__clacky_ec=\$\?;?[^\n]*\n?/,
427
+ ""
428
+ )
429
+
356
430
  text
357
431
  end
358
432
 
@@ -1188,7 +1188,7 @@ module Clacky
1188
1188
  display_name = "#{type_badge}#{model_name} (#{masked_key})"
1189
1189
  choices << {
1190
1190
  name: display_name,
1191
- value: { action: :switch, index: idx }
1191
+ value: { action: :switch, model_id: model["id"] }
1192
1192
  }
1193
1193
  end
1194
1194
 
@@ -1210,8 +1210,15 @@ module Clacky
1210
1210
 
1211
1211
  case result[:action]
1212
1212
  when :switch
1213
- current_config.switch_model(result[:index])
1214
- # Auto-save after switching
1213
+ # CLI is a single-session context: when the user picks a model
1214
+ # we treat it as "make this my default from now on". So:
1215
+ # 1. switch this session's current model
1216
+ # 2. move the global `type: "default"` marker to it
1217
+ # 3. persist to config.yml so next CLI launch uses it
1218
+ # (Web UI's per-session switch is different — it must NOT do
1219
+ # steps 2 and 3, and uses switch_model_by_id directly.)
1220
+ current_config.switch_model_by_id(result[:model_id])
1221
+ current_config.set_default_model_by_id(result[:model_id])
1215
1222
  current_config.save
1216
1223
  # Return to indicate config changed (need to update client)
1217
1224
  return { action: :switch }
@@ -1228,10 +1235,12 @@ module Clacky
1228
1235
  base_url: new_model[:base_url],
1229
1236
  anthropic_format: anthropic_format
1230
1237
  )
1231
- # Auto-save after adding
1232
- current_config.save
1233
- # Set newly added model as default
1234
- current_config.switch_model(current_config.models.length - 1)
1238
+ # CLI: adding a model implies the user wants to use it now and
1239
+ # next launch. Switch this session to it AND set it as the
1240
+ # global default, then persist.
1241
+ new_id = current_config.models.last["id"]
1242
+ current_config.switch_model_by_id(new_id)
1243
+ current_config.set_default_model_by_id(new_id)
1235
1244
  current_config.save
1236
1245
  # Return to exit the menu
1237
1246
  return { action: :switch }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.35"
4
+ VERSION = "0.9.36"
5
5
  end