openclacky 1.3.5 → 1.3.6

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.
@@ -15,6 +15,7 @@ require "securerandom"
15
15
  require "timeout"
16
16
  require "yaml"
17
17
  require "date"
18
+ require "open3"
18
19
  require_relative "session_registry"
19
20
  require_relative "git_panel"
20
21
  require_relative "web_ui_controller"
@@ -468,8 +469,8 @@ module Clacky
468
469
  30
469
470
  elsif path == "/api/media/video/understand"
470
471
  60
471
- elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
472
- # Building a tar.gz of ~/.clacky (with session history) can take a while.
472
+ elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
473
+ # Building/extracting a tar.gz of ~/.clacky can take a while.
473
474
  120
474
475
  else
475
476
  10
@@ -525,6 +526,8 @@ module Clacky
525
526
  when ["GET", "/api/backup/status"] then api_backup_status(res)
526
527
  when ["POST", "/api/backup/run"] then api_backup_run(res)
527
528
  when ["GET", "/api/backup/download"] then api_backup_download(res)
529
+ when ["POST", "/api/backup/restore"] then api_backup_restore(req, res)
530
+ when ["POST", "/api/backup/open-folder"] then api_backup_open_folder(res)
528
531
  when ["PATCH", "/api/backup/config"] then api_backup_config(req, res)
529
532
  when ["POST", "/api/telemetry"] then api_telemetry(req, res)
530
533
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
@@ -736,30 +739,28 @@ module Clacky
736
739
  # ── REST API ──────────────────────────────────────────────────────────────
737
740
 
738
741
  def api_list_sessions(req, res)
739
- query = URI.decode_www_form(req.query_string.to_s).to_h
740
- limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
741
- before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
742
- q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
743
- q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
744
- date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
745
- type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
742
+ query = URI.decode_www_form(req.query_string.to_s).to_h
743
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
744
+ before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
745
+ q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
746
+ q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
747
+ date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
748
+ type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
749
+ exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
746
750
  # Backward-compat: ?source=<x> and ?profile=coding → type
747
751
  type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
748
752
  type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
749
753
 
750
- # Fetch one extra NON-PINNED row to detect has_more without a separate count query.
751
- # `registry.list` always returns ALL matching pinned rows first (on the
752
- # first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
753
- # So has_more is determined by whether the non-pinned section overflowed.
754
- sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
754
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
755
755
 
756
- # Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
757
756
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
758
757
  has_more = non_pinned_part.size > limit
759
758
  non_pinned_part = non_pinned_part.first(limit)
760
759
  sessions = pinned_part + non_pinned_part
761
760
 
762
- json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
761
+ stats = @registry.cron_stats
762
+ json_response(res, 200, { sessions: sessions, has_more: has_more,
763
+ cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
763
764
  end
764
765
 
765
766
  # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
@@ -4876,6 +4877,68 @@ module Clacky
4876
4877
  })
4877
4878
  end
4878
4879
 
4880
+ # POST /api/backup/restore — accept a tar.gz upload, extract over ~/.clacky, hot-restart
4881
+ def api_backup_restore(req, res)
4882
+ body = req.body.to_s
4883
+ return json_response(res, 400, { error: "Empty body" }) if body.empty?
4884
+
4885
+ clacky_dir = File.expand_path("~/.clacky")
4886
+ stamp = Time.now.strftime("%Y%m%d-%H%M%S")
4887
+ tmp_archive = File.join(Dir.tmpdir, "clacky-restore-#{stamp}.tar.gz")
4888
+ tmp_backup = File.join(Dir.tmpdir, "clacky-pre-restore-#{stamp}")
4889
+
4890
+ File.binwrite(tmp_archive, body)
4891
+
4892
+ FileUtils.cp_r(clacky_dir, tmp_backup)
4893
+
4894
+ result = system("tar", "-xzf", tmp_archive, "-C", clacky_dir)
4895
+ unless result
4896
+ FileUtils.rm_rf(clacky_dir)
4897
+ FileUtils.cp_r(tmp_backup, clacky_dir)
4898
+ return json_response(res, 500, { error: "Failed to extract archive" })
4899
+ end
4900
+
4901
+ json_response(res, 200, { ok: true })
4902
+
4903
+ Thread.new do
4904
+ sleep 0.5
4905
+ if @master_pid
4906
+ begin
4907
+ Process.kill("USR1", @master_pid)
4908
+ rescue Errno::ESRCH
4909
+ standalone_exec_restart
4910
+ end
4911
+ else
4912
+ standalone_exec_restart
4913
+ end
4914
+ end
4915
+ rescue => e
4916
+ json_response(res, 500, { ok: false, error: e.message })
4917
+ ensure
4918
+ FileUtils.rm_f(tmp_archive) if tmp_archive
4919
+ FileUtils.rm_rf(tmp_backup) if tmp_backup && Dir.exist?(tmp_backup)
4920
+ end
4921
+
4922
+ # POST /api/backup/open-folder — open the backup destination in Finder/Explorer
4923
+ def api_backup_open_folder(res)
4924
+ dest = BackupManager.status["dest_dir"]
4925
+ FileUtils.mkdir_p(dest)
4926
+ host_os = RbConfig::CONFIG["host_os"]
4927
+ if host_os =~ /darwin/
4928
+ system("open", dest)
4929
+ elsif host_os =~ /linux/
4930
+ if File.exist?("/proc/version") && File.read("/proc/version").downcase.include?("microsoft")
4931
+ windows_path, = Open3.capture2("wslpath", "-w", dest)
4932
+ system("explorer.exe", windows_path.strip) unless windows_path.strip.empty?
4933
+ else
4934
+ system("xdg-open", dest)
4935
+ end
4936
+ end
4937
+ json_response(res, 200, { ok: true, dest_dir: dest })
4938
+ rescue => e
4939
+ json_response(res, 500, { ok: false, error: e.message })
4940
+ end
4941
+
4879
4942
  # Resolve the AgentConfig for a given session, falling back to nil when
4880
4943
  # the session isn't live so callers can use the global config instead.
4881
4944
  def config_for_session(session_id)
@@ -5722,12 +5785,12 @@ module Clacky
5722
5785
  interrupt_session(session_id)
5723
5786
 
5724
5787
  when "list_sessions"
5725
- # Initial load: newest 20 sessions regardless of source/profile.
5726
- # Single unified query frontend shows all in one time-sorted list.
5727
- page = @registry.list(limit: 21)
5788
+ stats = @registry.cron_stats
5789
+ page = @registry.list(limit: 21, exclude_type: "cron")
5728
5790
  has_more = page.size > 20
5729
5791
  all_sessions = page.first(20)
5730
- conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, cron_count: @registry.cron_count)
5792
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
5793
+ cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
5731
5794
 
5732
5795
  when "run_task"
5733
5796
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -5822,112 +5885,30 @@ module Clacky
5822
5885
 
5823
5886
  # Interrupt a running agent session.
5824
5887
  #
5825
- # Thread#raise alone is not reliable enough in practice it's
5826
- # best-effort against blocked syscalls (socket writes, OpenSSL read,
5827
- # ConditionVariable#wait with a held mutex) and we've seen sessions
5828
- # that stay "running" forever even after multiple interrupt attempts.
5829
- #
5830
- # Strategy: three-tier escalation in a background watchdog Thread so
5831
- # the HTTP handler returns immediately.
5832
- #
5833
- # Tier 1 (t=0): Thread#raise(AgentInterrupted).
5834
- # Unblocks most pure-Ruby waits and Faraday reads.
5835
- # Handles the common case.
5836
- # Tier 2 (t=3): force-close this session's WebSocket connections
5837
- # so any send_raw stuck on socket write wakes up.
5838
- # Try Thread#raise again (idempotent).
5839
- # Tier 3 (t=8): Thread#kill — last resort. Leaks any held
5840
- # resources but frees the session so the user can
5841
- # move on.
5888
+ # Thread#raise is best-effort: it unblocks most pure-Ruby waits and
5889
+ # Faraday reads, but can't reach a thread stuck in a C-extension syscall
5890
+ # until that syscall returns. We raise once and return immediately.
5842
5891
  #
5843
- # Each transition is logged so that when users report "stuck
5844
- # sessions" we can see in the log whether tier 2/3 ever had to
5845
- # fire that's our signal to dig deeper on the underlying block.
5892
+ # Correctness of the *takeover* does not depend on the old thread dying
5893
+ # promptly: each new task claims a fresh epoch (see run_agent_task), and
5894
+ # any status write or UI broadcast from a superseded thread is fenced off
5895
+ # by that epoch. A stale thread that lingers in a syscall is harmless — it
5896
+ # self-terminates at the next check_stale! checkpoint, or when the syscall
5897
+ # returns; either way it can no longer touch the live session.
5846
5898
  def interrupt_session(session_id)
5847
- thread = nil
5848
5899
  @registry.with_session(session_id) do |s|
5849
5900
  s[:idle_timer]&.cancel
5850
5901
  thread = s[:thread]
5851
-
5852
5902
  next unless thread&.alive?
5853
5903
 
5854
- Clacky::Logger.info("[interrupt] session=#{session_id} tier=1 raise")
5904
+ Clacky::Logger.info("[interrupt] session=#{session_id} raise")
5855
5905
  begin
5856
5906
  thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
5857
5907
  rescue ThreadError => e
5858
- Clacky::Logger.warn("[interrupt] tier=1 raise failed: #{e.message}")
5908
+ Clacky::Logger.warn("[interrupt] raise failed: #{e.message}")
5859
5909
  end
5860
5910
  end
5861
-
5862
- return unless thread&.alive?
5863
-
5864
- start_interrupt_watchdog(session_id, thread)
5865
- end
5866
-
5867
- # Background watchdog: escalates from WebSocket force-close (tier 2)
5868
- # to Thread#kill (tier 3) if the agent thread refuses to die.
5869
- private def start_interrupt_watchdog(session_id, thread)
5870
- Thread.new do
5871
- Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
5872
-
5873
- # Give the first Thread#raise a few seconds to unwind.
5874
- sleep 3
5875
- next unless thread.alive?
5876
-
5877
- Clacky::Logger.warn(
5878
- "[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
5879
- "force-closing session resources"
5880
- )
5881
- force_close_session_sockets(session_id)
5882
- # Re-raise — sometimes the first raise was swallowed deep in a
5883
- # C-extension syscall; after we force-close the socket the
5884
- # syscall returns and the next raise sticks.
5885
- begin
5886
- thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
5887
- rescue ThreadError
5888
- # already dead between checks — fine
5889
- end
5890
-
5891
- sleep 5
5892
- next unless thread.alive?
5893
-
5894
- Clacky::Logger.error(
5895
- "[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
5896
- )
5897
- begin
5898
- thread.kill
5899
- rescue StandardError => e
5900
- Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
5901
- end
5902
-
5903
- # Record the forced-kill so the UI can show a warning and operators
5904
- # can correlate with any backtrace dumps. The session is left in
5905
- # :idle state by run_agent_task's rescue clause; if the kill
5906
- # happened before the rescue could run, patch the state directly.
5907
- begin
5908
- @registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
5909
- broadcast_session_update(session_id)
5910
- rescue StandardError
5911
- # best effort
5912
- end
5913
- end
5914
- end
5915
-
5916
- # Close every WebSocket connection bound to the given session. Used by
5917
- # the interrupt watchdog to unblock agent threads stuck in a WS write.
5918
- private def force_close_session_sockets(session_id)
5919
- conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
5920
- conns.each do |conn|
5921
- Clacky::Logger.warn(
5922
- "[interrupt] session=#{session_id} force-closing WS conn"
5923
- )
5924
- conn.force_close!
5925
- end
5926
- rescue StandardError => e
5927
- Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
5928
- end
5929
-
5930
- # Run a task in a session immediately in the background, without waiting
5911
+ end # Run a task in a session immediately in the background, without waiting
5931
5912
  # for the client to subscribe. The user bubble is persisted via
5932
5913
  # display_text (Agent#run → history → replay_history), so the frontend
5933
5914
  # only needs to navigate over and load history — no realtime broadcast,
@@ -6008,11 +5989,19 @@ module Clacky
6008
5989
  # Cancel any pending idle compression before starting a new task
6009
5990
  idle_timer&.cancel
6010
5991
 
6011
- # Mark running BEFORE evict_excess_idle! otherwise this session
5992
+ # Claim a fresh epoch and mark running atomically-ish. The epoch
5993
+ # fences this task against an interrupted-but-not-yet-dead predecessor:
5994
+ # the old thread compares its epoch before writing status or
5995
+ # broadcasting and silently drops anything once superseded. Without
5996
+ # this a slow old thread could overwrite :running back to :idle (or
5997
+ # close the new task's sockets), leaving the UI stuck "running".
5998
+ #
5999
+ # Marked running BEFORE evict_excess_idle! — otherwise this session
6012
6000
  # (still :idle here) can be evicted from the registry along with
6013
6001
  # other idle agents, breaking subsequent status updates and any
6014
6002
  # follow-up handle_user_message (which would early-return on
6015
6003
  # @registry.exist? == false).
6004
+ epoch = @registry.claim_epoch(session_id)
6016
6005
  @registry.update(session_id, status: :running)
6017
6006
 
6018
6007
  # evict_excess_idle! serializes + writes 1 file per evicted session
@@ -6028,8 +6017,9 @@ module Clacky
6028
6017
  locale = Thread.current[:lang]
6029
6018
  thread = Thread.new do
6030
6019
  Thread.current[:lang] = locale
6020
+ Thread.current[:task_epoch] = epoch
6031
6021
  task.call
6032
- @registry.update(session_id, status: :idle, error: nil)
6022
+ next unless @registry.update_if_epoch(session_id, epoch, status: :idle, error: nil)
6033
6023
  broadcast_session_update(session_id)
6034
6024
  # Transient global signal for the optional task-complete sound. Sent to
6035
6025
  # all clients (broadcast_all) so a browser viewing another session — or
@@ -6040,7 +6030,9 @@ module Clacky
6040
6030
  # Start idle compression timer now that the agent is idle
6041
6031
  idle_timer&.start
6042
6032
  rescue Clacky::AgentInterrupted
6043
- @registry.update(session_id, status: :idle)
6033
+ # A superseding task already owns the session — do not touch status
6034
+ # or push UI events, they belong to the new epoch now.
6035
+ next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
6044
6036
  broadcast_session_update(session_id)
6045
6037
  broadcast(session_id, { type: "interrupted", session_id: session_id })
6046
6038
  @session_manager.save(agent.to_session_data(status: :interrupted))
@@ -6056,12 +6048,14 @@ module Clacky
6056
6048
  end
6057
6049
  user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
6058
6050
  raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
6059
- @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
6051
+ next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
6060
6052
  broadcast_session_update(session_id)
6061
6053
  web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
6062
6054
  @session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
6063
6055
  end
6064
- @registry.with_session(session_id) { |s| s[:thread] = thread }
6056
+ # Register the thread only if we still own the epoch; a faster
6057
+ # superseding task may have already replaced it.
6058
+ @registry.with_session(session_id) { |s| s[:thread] = thread if s[:epoch].to_i == epoch.to_i }
6065
6059
  end
6066
6060
 
6067
6061
  # ── WebSocket subscription management ─────────────────────────────────────
@@ -6091,6 +6085,15 @@ module Clacky
6091
6085
  # removed automatically. Connections already marked closed are skipped
6092
6086
  # upfront so one sluggish client can't delay delivery to healthy ones.
6093
6087
  def broadcast(session_id, event)
6088
+ # Drop events emitted by a superseded agent thread. A thread carrying a
6089
+ # :task_epoch only gets through while it still owns the session; an
6090
+ # interrupted-but-unwinding old thread (e.g. a late show_progress
6091
+ # "done" from an ensure block) is silently dropped so it can't disturb
6092
+ # the new task's UI. Threads without :task_epoch (HTTP handlers, the
6093
+ # task supervisor itself) are never affected.
6094
+ my_epoch = Thread.current[:task_epoch]
6095
+ return if my_epoch && @registry.current_epoch(session_id) != my_epoch
6096
+
6094
6097
  clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
6095
6098
  dead = []
6096
6099
  clients.each do |conn|
@@ -49,6 +49,7 @@ module Clacky
49
49
  agent: nil,
50
50
  ui: nil,
51
51
  thread: nil,
52
+ epoch: 0,
52
53
  idle_timer: nil,
53
54
  pending_task: nil,
54
55
  pending_working_dir: nil
@@ -138,6 +139,40 @@ module Clacky
138
139
  end
139
140
  end
140
141
 
142
+ # Atomically bump the session's task epoch and return the new value.
143
+ # Each user message that starts a task claims a fresh epoch; a stale task
144
+ # thread (interrupted but not yet dead) compares its epoch against the
145
+ # current one and discards any side effects (status writes, broadcasts)
146
+ # once it has been superseded.
147
+ def claim_epoch(session_id)
148
+ @mutex.synchronize do
149
+ session = @sessions[session_id]
150
+ return nil unless session
151
+
152
+ session[:epoch] = session[:epoch].to_i + 1
153
+ end
154
+ end
155
+
156
+ # Current task epoch for a session (0 if none / unknown).
157
+ def current_epoch(session_id)
158
+ @mutex.synchronize { @sessions[session_id]&.fetch(:epoch, 0).to_i }
159
+ end
160
+
161
+ # Update fields only if the caller still owns the current epoch. Returns
162
+ # true if the update was applied, false if the epoch was stale (a newer
163
+ # task has taken over) or the session is gone.
164
+ def update_if_epoch(session_id, epoch, **fields)
165
+ @mutex.synchronize do
166
+ session = @sessions[session_id]
167
+ return false unless session
168
+ return false unless session[:epoch].to_i == epoch.to_i
169
+
170
+ fields[:updated_at] = Time.now
171
+ session.merge!(fields)
172
+ true
173
+ end
174
+ end
175
+
141
176
  # Return a session list from disk enriched with live registry status.
142
177
  # Sorted by created_at descending (newest first).
143
178
  #
@@ -161,7 +196,7 @@ module Clacky
161
196
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
162
197
  #
163
198
  # source and profile are orthogonal — either can be nil independently.
164
- def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, include_pinned: true)
199
+ def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true)
165
200
  return [] unless @session_manager
166
201
 
167
202
  live = @mutex.synchronize do
@@ -196,6 +231,12 @@ module Clacky
196
231
  end
197
232
  end
198
233
 
234
+ # ── exclude_type filter ───────────────────────────────────────────────
235
+ if exclude_type
236
+ excluded = Array(exclude_type)
237
+ all = all.reject { |s| excluded.include?(s_source(s)) }
238
+ end
239
+
199
240
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
200
241
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
201
242
 
@@ -349,6 +390,14 @@ module Clacky
349
390
  @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
350
391
  end
351
392
 
393
+ # Count + latest updated_at for cron sessions (used by sidebar virtual entry).
394
+ def cron_stats
395
+ return { count: 0, latest_updated_at: nil } unless @session_manager
396
+ cron_all = @session_manager.all_sessions.select { |s| s_source(s) == "cron" }
397
+ latest = cron_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
398
+ { count: cron_all.size, latest_updated_at: latest }
399
+ end
400
+
352
401
  # Delete a session from registry (and interrupt its thread).
353
402
  def delete(session_id)
354
403
  @mutex.synchronize do
@@ -35,7 +35,7 @@ module Clacky
35
35
 
36
36
  # Keep only the most recent 200 sessions (best-effort, never block save)
37
37
  begin
38
- cleanup_by_count(keep: 200)
38
+ cleanup_by_count(keep: 200, keep_cron: 200)
39
39
  rescue Exception # rubocop:disable Lint/RescueException
40
40
  # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
41
41
  end
@@ -163,6 +163,33 @@ module Clacky
163
163
  chunk_path
164
164
  end
165
165
 
166
+ # Read the raw markdown of a chunk file. Returns nil if missing.
167
+ def read_chunk(chunk_path)
168
+ return nil unless chunk_path && File.exist?(chunk_path)
169
+ File.read(chunk_path)
170
+ end
171
+
172
+ # Split raw chunk markdown into [front_matter_hash, body_string].
173
+ # front_matter_hash preserves insertion order; body is everything after
174
+ # the closing "---". Returns [nil, raw] when there is no leading block.
175
+ def split_chunk_md(raw)
176
+ return [nil, raw.to_s] unless raw.to_s.start_with?("---")
177
+
178
+ fm_end = raw.index("\n---\n", 4)
179
+ return [nil, raw] unless fm_end
180
+
181
+ fm_text = raw[4...fm_end]
182
+ body = raw[(fm_end + 5)..] || ""
183
+
184
+ fm = {}
185
+ fm_text.each_line do |line|
186
+ k, _, v = line.chomp.partition(":")
187
+ next if k.strip.empty?
188
+ fm[k.strip] = v.strip
189
+ end
190
+ [fm, body]
191
+ end
192
+
166
193
  # All sessions from disk, newest-first (sorted by last activity / updated_at,
167
194
  # falling back to created_at for legacy sessions without updated_at).
168
195
  # Optional filters:
@@ -305,11 +332,15 @@ module Clacky
305
332
  # are soft-deleted (moved to the session trash, recoverable). Pinned
306
333
  # sessions are never deleted and do not count toward the cap.
307
334
  # Returns count of soft-deleted sessions.
308
- def cleanup_by_count(keep:)
335
+ def cleanup_by_count(keep:, keep_cron: 200)
309
336
  non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
310
- return 0 if non_pinned.size <= keep
311
337
 
312
- victims = non_pinned[keep..]
338
+ cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
339
+
340
+ victims = []
341
+ victims += regular[keep..] if regular.size > keep
342
+ victims += cron[keep_cron..] if cron.size > keep_cron
343
+
313
344
  victims.each { |session| soft_delete(session[:session_id]) }
314
345
  victims.size
315
346
  end
@@ -120,28 +120,23 @@ module Clacky
120
120
  @render_mutex.synchronize do
121
121
  entry = @buffer.entry_by_id(id)
122
122
  if entry.nil?
123
- Clacky::Logger.warn("[ph_debug] replace_entry_nil", id: id, content: content.to_s[0, 120])
124
123
  return
125
124
  end
126
125
  if entry.committed
127
- Clacky::Logger.warn("[ph_debug] replace_entry_committed", id: id, content: content.to_s[0, 120])
128
126
  return
129
127
  end
130
128
  if (entry.committed_line_offset || 0) > 0
131
- Clacky::Logger.warn("[ph_debug] replace_entry_partial", id: id, offset: entry.committed_line_offset, content: content.to_s[0, 120])
132
129
  return
133
130
  end
134
131
 
135
132
  old_lines = entry.lines.dup
136
133
  new_lines = wrap_content_to_lines(content)
137
134
  if old_lines == new_lines
138
- Clacky::Logger.warn("[ph_debug] replace_entry_same", id: id)
139
135
  screen.flush
140
136
  return
141
137
  end
142
138
  @buffer.replace(id, new_lines)
143
139
  is_tail = @buffer.live_entries.last&.id == id
144
- Clacky::Logger.warn("[ph_debug] replace_entry_paint", id: id, is_tail: is_tail, old_n: old_lines.length, new_n: new_lines.length, content: content.to_s[0, 120])
145
140
 
146
141
  unless @fullscreen_mode
147
142
  # repaint_entry_in_place relies on the entry being the tail of
@@ -182,7 +182,6 @@ module Clacky
182
182
  # @param final_message [String, nil] Optional override for the last
183
183
  # frame. If nil, the handle composes "<message>… (<elapsed>s)".
184
184
  def finish(final_message: nil)
185
- Clacky::Logger.warn("[ph_debug] finish_entry", oid: object_id, state: @state, unreg: @unregistered, msg: @message, eid: @entry_id)
186
185
  snapshot = @monitor.synchronize do
187
186
  return if @unregistered
188
187
  first_close = @state == :running
@@ -201,10 +200,8 @@ module Clacky
201
200
  else
202
201
  compose_final_frame(snapshot[:message], snapshot[:elapsed])
203
202
  end
204
- Clacky::Logger.warn("[ph_debug] finish_unregister", oid: object_id, eid: @entry_id, first_close: snapshot[:first_close], final_frame: final_frame.to_s[0, 200])
205
203
  @owner.unregister_progress(self, final_frame: final_frame)
206
204
  @monitor.synchronize { @unregistered = true }
207
- Clacky::Logger.warn("[ph_debug] finish_done", oid: object_id)
208
205
  end
209
206
  alias_method :cancel, :finish
210
207
 
@@ -664,22 +664,13 @@ module Clacky
664
664
 
665
665
  # Called by ProgressHandle#finish.
666
666
  def unregister_progress(handle, final_frame:)
667
- Clacky::Logger.warn("[ph_debug] unreg_entry", oid: handle.object_id, eid: handle.entry_id, top: @progress_stack.last == handle, stack_size: @progress_stack.size, ff: final_frame.to_s[0, 200])
668
667
  @progress_mutex.synchronize do
669
- # If this handle still holds its entry (it's currently top), we
670
- # render one last frame there and release the id. If it was
671
- # previously detached (someone above is still active), its entry
672
- # is already gone and the final_frame is simply dropped.
673
668
  if handle.entry_id
674
669
  if final_frame && !final_frame.to_s.strip.empty?
675
- Clacky::Logger.warn("[ph_debug] unreg_update_entry", oid: handle.object_id, eid: handle.entry_id)
676
670
  update_entry(handle.entry_id, @renderer.render_progress(final_frame))
677
671
  else
678
- Clacky::Logger.warn("[ph_debug] unreg_remove_entry", oid: handle.object_id, eid: handle.entry_id)
679
672
  remove_entry(handle.entry_id)
680
673
  end
681
- else
682
- Clacky::Logger.warn("[ph_debug] unreg_no_entry_id", oid: handle.object_id)
683
674
  end
684
675
 
685
676
  @progress_stack.delete(handle)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.5"
4
+ VERSION = "1.3.6"
5
5
  end
@@ -4486,6 +4486,7 @@ body {
4486
4486
  border-radius: 10px;
4487
4487
  padding: 0.625rem 0.875rem;
4488
4488
  background: var(--color-bg-secondary);
4489
+ margin-bottom: 0.625rem;
4489
4490
  }
4490
4491
  .backup-auto-card .backup-auto-row {
4491
4492
  margin: 0.25rem 0;
@@ -4517,6 +4518,43 @@ body {
4517
4518
  font-size: 0.75rem;
4518
4519
  color: var(--color-text-secondary);
4519
4520
  }
4521
+ .backup-dest-row {
4522
+ display: flex;
4523
+ align-items: center;
4524
+ gap: 0.5rem;
4525
+ margin: 0.625rem 0 0.25rem;
4526
+ padding-top: 0.625rem;
4527
+ border-top: 1px solid var(--color-border-primary);
4528
+ }
4529
+ .backup-dest-label {
4530
+ font-size: 0.78125rem;
4531
+ color: var(--color-text-secondary);
4532
+ white-space: nowrap;
4533
+ flex-shrink: 0;
4534
+ }
4535
+ .backup-dest-path {
4536
+ font-size: 0.78125rem;
4537
+ color: var(--color-text-primary);
4538
+ font-family: var(--font-mono, monospace);
4539
+ overflow: hidden;
4540
+ text-overflow: ellipsis;
4541
+ white-space: nowrap;
4542
+ }
4543
+ .btn-settings-link {
4544
+ flex-shrink: 0;
4545
+ background: transparent;
4546
+ border: 1px solid var(--color-border-primary);
4547
+ border-radius: 6px;
4548
+ color: var(--color-accent-primary);
4549
+ font-size: 0.75rem;
4550
+ padding: 0.25rem 0.625rem;
4551
+ cursor: pointer;
4552
+ white-space: nowrap;
4553
+ }
4554
+ .btn-settings-link:hover {
4555
+ background: var(--color-bg-hover);
4556
+ border-color: var(--color-accent-primary);
4557
+ }
4520
4558
  .backup-actions {
4521
4559
  display: flex;
4522
4560
  align-items: center;
@@ -4524,6 +4562,21 @@ body {
4524
4562
  flex-wrap: wrap;
4525
4563
  }
4526
4564
 
4565
+ .backup-config-row {
4566
+ margin-top: 0.5rem;
4567
+ padding-top: 0.75rem;
4568
+ border-top: 1px solid var(--color-border);
4569
+ }
4570
+ .btn-settings-action--file {
4571
+ cursor: pointer;
4572
+ }
4573
+ .backup-actions-sep {
4574
+ width: 1px;
4575
+ height: 1.25rem;
4576
+ background: var(--color-border);
4577
+ flex-shrink: 0;
4578
+ }
4579
+
4527
4580
  .settings-network-url {
4528
4581
  display: flex;
4529
4582
  flex-direction: column;
@@ -7548,7 +7601,7 @@ body.setup-mode[data-theme="dark"] {
7548
7601
  cursor: pointer;
7549
7602
  transition: background .15s, opacity .15s;
7550
7603
  }
7551
- .setup-submit-btn:hover { background: #2563eb; }
7604
+ .setup-submit-btn:hover { background: var(--color-button-primary-hover); }
7552
7605
  .setup-submit-btn:disabled { opacity: .5; cursor: default; }
7553
7606
 
7554
7607
  /* ── Onboard panel ───────────────────────────────────────────────────────── */