openclacky 1.2.17 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/skill_manager.rb +1 -1
  4. data/lib/clacky/agent/time_machine.rb +256 -74
  5. data/lib/clacky/agent/tool_executor.rb +12 -0
  6. data/lib/clacky/agent.rb +21 -31
  7. data/lib/clacky/agent_config.rb +18 -0
  8. data/lib/clacky/cli.rb +55 -3
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
  10. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
  11. data/lib/clacky/media/base.rb +125 -0
  12. data/lib/clacky/media/dashscope.rb +243 -0
  13. data/lib/clacky/media/gemini.rb +10 -0
  14. data/lib/clacky/media/generator.rb +75 -0
  15. data/lib/clacky/media/openai_compat.rb +160 -0
  16. data/lib/clacky/message_history.rb +12 -7
  17. data/lib/clacky/providers.rb +28 -0
  18. data/lib/clacky/rich_ui_controller.rb +3 -1
  19. data/lib/clacky/server/backup_manager.rb +200 -0
  20. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  21. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  22. data/lib/clacky/server/channel/channel_manager.rb +180 -81
  23. data/lib/clacky/server/http_server.rb +348 -15
  24. data/lib/clacky/server/scheduler.rb +19 -0
  25. data/lib/clacky/server/session_registry.rb +8 -4
  26. data/lib/clacky/session_manager.rb +40 -2
  27. data/lib/clacky/skill.rb +3 -1
  28. data/lib/clacky/tools/trash_manager.rb +14 -0
  29. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  30. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  31. data/lib/clacky/ui2/ui_controller.rb +150 -19
  32. data/lib/clacky/utils/file_processor.rb +75 -4
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +2038 -1147
  35. data/lib/clacky/web/app.js +22 -1
  36. data/lib/clacky/web/backup.js +119 -0
  37. data/lib/clacky/web/billing.js +94 -7
  38. data/lib/clacky/web/channels.js +81 -11
  39. data/lib/clacky/web/design-sample.css +247 -0
  40. data/lib/clacky/web/design-sample.html +127 -0
  41. data/lib/clacky/web/favicon.svg +16 -0
  42. data/lib/clacky/web/i18n.js +159 -31
  43. data/lib/clacky/web/index.html +175 -55
  44. data/lib/clacky/web/logo_nav_dark.png +0 -0
  45. data/lib/clacky/web/onboard.js +114 -28
  46. data/lib/clacky/web/sessions.js +436 -192
  47. data/lib/clacky/web/settings.js +21 -1
  48. data/lib/clacky/web/skills.js +6 -6
  49. data/lib/clacky/web/tasks.js +129 -61
  50. data/lib/clacky/web/utils.js +72 -0
  51. data/lib/clacky/web/ws-dispatcher.js +6 -0
  52. data/lib/clacky.rb +1 -0
  53. metadata +8 -3
  54. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a0f79d1b7995b24b2b1a5d31dc36d4342ad1ff925b66749b7edb6268d0bcf29
4
- data.tar.gz: b1f583cde8ffb619a4cb3558f074c369251c5445acce05116d1bc415e94eb3b4
3
+ metadata.gz: 8423d4e64f056251f1763e5cc34502e394f08e2b26de993da5da6a85a88111ef
4
+ data.tar.gz: 62e60aee3f8654e881870117b4716e053ab0f9328bc3898392ff1ed27517474c
5
5
  SHA512:
6
- metadata.gz: 18921df7e5d4d4a7f3cbf1e19bd1b65bbc8360fa064d81ffc129375d86ec9a07ebfc324414d67a7d6df9782dfd8f966065755d0224d8af77b92604d6320195e2
7
- data.tar.gz: 013db4dddea3c901bd3e8885241676b7fdd2ca2856a9a4df4cfde4b2475b7e57dc7795386d53db934acfe5786515501fd724e3ef559e8d5e786a6b07a3312db6
6
+ metadata.gz: d675c6b981a1fc5f24bbee79dd19dc273817a7f4e233c2ed86a42def4f2fd2876cbc845a9f693383d7f4b67d39f6a62df1eafa65def2a8ace7bb876559c6aba5
7
+ data.tar.gz: 48adb156c4c8ab26d2908537ac409c161a56b1f6fa5aa2146bdaa5df6b11c2b333e5a04a6aafc4404f1c94ce210fe0c23854d0f86f9cc509f1a27a781f9aa007
data/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-06-17
9
+
10
+ ### Added
11
+ - Complete Web UI overhaul: skeleton loading, task card layout, new session dialog, onboarding flow, inline image preview, and redesigned session bar
12
+ - Multimedia generation: video generation, text-to-speech (TTS), and OCR via vision models
13
+ - IM channel management UI — bind and unbind channels directly in Web UI
14
+ - `/model` command in terminal UI for quick model switching
15
+ - AI-key device login flow
16
+ - Session sharing and backup support
17
+
18
+ ### Improved
19
+ - Terminal output auto-collapses on completion with normalized result display
20
+ - Billing page mobile layout, global tooltip, and logo animation polish
21
+ - Live chat history API replaces group buffer polling for real-time sync
22
+
23
+ ### Fixed
24
+ - Sidebar scrolling back to active session on content updates
25
+ - Channel key missing arbitration on session restore
26
+ - Race condition in model switching
27
+ - Channel keys and info out of sync on bind/unbind
28
+
29
+ ## [1.2.18] - 2026-06-13
30
+
31
+ ### Added
32
+ - Alibaba DashScope (Qwen-Image) as a new image generation backend
33
+ - "Always show" toggle for media-gen and skill-creators default skills, keeping them visible in all sessions
34
+
35
+ ### Fixed
36
+ - Brand skill files not accessible outside their initial session context
37
+ - `/model` command
38
+
39
+ ### More
40
+ - Brand skills page now auto-refreshes on enter
41
+
8
42
  ## [1.2.17] - 2026-06-12
9
43
 
10
44
  ### Added
@@ -346,7 +346,7 @@ module Clacky
346
346
 
347
347
  # For encrypted brand skills with supporting scripts: decrypt to a tmpdir so the
348
348
  # LLM receives the real paths it can execute. The tmpdir is registered on the agent
349
- # and shredded when agent.run completes (see Agent#shred_script_tmpdirs).
349
+ # and lives for the agent's lifetime (the session).
350
350
  script_dir = nil
351
351
  if skill.encrypted? && skill.has_supporting_files?
352
352
  script_dir = Dir.mktmpdir("clacky-skill-#{skill.identifier}-")
@@ -1,10 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "set"
5
+
3
6
  module Clacky
4
7
  class Agent
5
- # Time Machine module for task history management with undo/redo support
6
- # Stores complete file snapshots (AFTER state) to support message compression
8
+ # Time Machine module for task history management with undo/redo support.
9
+ #
10
+ # Snapshots capture the BEFORE state of each file the moment a task first
11
+ # touches it (via record_file_before_change). task-N/ therefore holds
12
+ # "what every file looked like just before task N changed it" — including
13
+ # an .absent marker for files that did not yet exist. Restoring to task T
14
+ # replays the earliest BEFORE recorded in any task after T, which equals
15
+ # the on-disk state at the end of task T.
7
16
  module TimeMachine
17
+ # Marker file written alongside a snapshot path when the original file
18
+ # did not exist before the task changed it. Restoring such an entry
19
+ # deletes the file instead of copying content back.
20
+ ABSENT_MARKER = ".clacky-absent"
21
+
22
+ # Root directory holding per-session file snapshots.
23
+ def self.snapshots_root
24
+ File.join(Dir.home, ".clacky", "snapshots")
25
+ end
26
+
27
+ # Snapshot directory for a single session.
28
+ def self.session_dir(session_id)
29
+ File.join(snapshots_root, session_id.to_s)
30
+ end
31
+
32
+ # Remove all snapshots for a session. Safe to call when none exist.
33
+ def self.delete_session_snapshots(session_id)
34
+ return if session_id.to_s.empty?
35
+
36
+ FileUtils.rm_rf(session_dir(session_id))
37
+ end
38
+
8
39
  # Initialize Time Machine state
9
40
  private def init_time_machine
10
41
  @task_parents ||= {} # { task_id => parent_id }
@@ -15,6 +46,12 @@ module Clacky
15
46
  # Start a new task and establish parent relationship
16
47
  # Made public for testing
17
48
  def start_new_task
49
+ # Before the currently-active task stops being the latest, freeze its
50
+ # end-of-task disk state into an AFTER snapshot. Without this, a task
51
+ # that later gets superseded by a sibling branch would have no record
52
+ # of its result, making a forward switch back to it impossible.
53
+ checkpoint_latest_task_after
54
+
18
55
  parent_id = @active_task_id
19
56
  @current_task_id += 1
20
57
  @active_task_id = @current_task_id
@@ -26,101 +63,242 @@ module Clacky
26
63
  # before it can write to history.
27
64
  @task_thread = Thread.current
28
65
 
66
+ @latest_after_dirty = true
67
+
29
68
  @current_task_id
30
69
  end
31
70
 
32
- # Save snapshots of modified files (AFTER state)
33
- # @param modified_files [Array<String>] List of file paths that were modified
71
+ # Record a file's BEFORE state for the current task, the first time the
72
+ # task touches it. Call this immediately before a tool mutates the file.
73
+ # Subsequent calls within the same task are no-ops so the earliest state
74
+ # (the true "before this task" snapshot) is preserved.
34
75
  # Made public for testing
35
- def save_modified_files_snapshot(modified_files)
36
- return if modified_files.nil? || modified_files.empty?
37
-
38
- snapshot_dir = File.join(
39
- Dir.home,
40
- ".clacky",
41
- "snapshots",
42
- @session_id,
43
- "task-#{@current_task_id}"
44
- )
45
- FileUtils.mkdir_p(snapshot_dir)
46
-
47
- modified_files.each do |file_path|
48
- next unless File.exist?(file_path)
49
-
50
- # Save file content to snapshot
51
- relative_path = file_path.start_with?(@working_dir) ?
52
- file_path.sub(@working_dir + "/", "") : File.basename(file_path)
53
-
54
- snapshot_file = File.join(snapshot_dir, relative_path)
55
- FileUtils.mkdir_p(File.dirname(snapshot_file))
56
- FileUtils.cp(file_path, snapshot_file)
76
+ def record_file_before_change(file_path)
77
+ return if @current_task_id.to_i <= 0
78
+
79
+ full_path = File.expand_path(file_path.to_s, @working_dir)
80
+ rel = snapshot_relative_path(full_path)
81
+ before_dir = File.join(TimeMachine.session_dir(@session_id), "task-#{@current_task_id}", "before")
82
+ snapshot_file = File.join(before_dir, rel)
83
+ marker_file = "#{snapshot_file}.#{ABSENT_MARKER}"
84
+
85
+ # Already recorded for this task — keep the earliest capture.
86
+ return if File.exist?(snapshot_file) || File.exist?(marker_file)
87
+
88
+ # A fresh change to the latest task invalidates its stale AFTER checkpoint.
89
+ @latest_after_dirty = true
90
+
91
+ FileUtils.mkdir_p(File.dirname(snapshot_file))
92
+ if File.exist?(full_path)
93
+ FileUtils.cp(full_path, snapshot_file)
94
+ else
95
+ # File did not exist before this task — mark it so a restore deletes it.
96
+ FileUtils.touch(marker_file)
57
97
  end
58
- rescue StandardError => e
59
- # Silently handle errors in tests
98
+ rescue StandardError
99
+ # Snapshotting must never break the actual file operation.
60
100
  end
61
101
 
62
- # Restore files to the state at given task
102
+ # Snapshot a task's current on-disk state into its AFTER tree, so a
103
+ # forward switch (redo / branch switch) back to it can be reconstructed.
104
+ # Only the files the task touched (its BEFORE entries) are captured.
105
+ # Defaults to the active task, which holds the live disk state right
106
+ # before we leave it (start_new_task / switch).
107
+ private def checkpoint_latest_task_after(task_id = @active_task_id)
108
+ return if task_id.to_i <= 0
109
+ # Re-snapshotting the latest task is skipped when nothing changed.
110
+ return if task_id == @current_task_id && @latest_after_dirty == false
111
+
112
+ session_root = TimeMachine.session_dir(@session_id)
113
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
114
+ return unless Dir.exist?(before_dir)
115
+
116
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
117
+ FileUtils.rm_rf(after_dir)
118
+
119
+ Dir.glob(File.join(before_dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
120
+ next if File.directory?(path)
121
+
122
+ rel = path.sub(before_dir + "/", "")
123
+ rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
124
+ target = File.join(@working_dir, rel)
125
+ dest = File.join(after_dir, rel)
126
+ if File.exist?(target)
127
+ FileUtils.mkdir_p(File.dirname(dest))
128
+ FileUtils.cp(target, dest)
129
+ else
130
+ FileUtils.mkdir_p(File.dirname(dest))
131
+ FileUtils.touch("#{dest}.#{ABSENT_MARKER}")
132
+ end
133
+ end
134
+ @latest_after_dirty = false if task_id == @current_task_id
135
+ rescue StandardError
136
+ # Checkpointing must never break a restore.
137
+ end
138
+
139
+ # Restore files to the on-disk state at the END of the given task.
140
+ #
141
+ # History is a TREE (undo + a new message forks a sibling branch), so a
142
+ # linear "replay every task after T" model is wrong: a sibling branch's
143
+ # files would leak in or get wrongly deleted. Instead we reconstruct T's
144
+ # end state from the task tree:
145
+ #
146
+ # * Each task owns an AFTER snapshot = the content of the files it
147
+ # touched, as they looked when that task finished.
148
+ # * To rebuild "end of task T", walk T's ancestor chain (T -> root).
149
+ # For every file ever touched in the whole session, the winning
150
+ # content is the closest ancestor (starting at T) whose AFTER holds
151
+ # that file. If no ancestor on the chain ever touched it, the file
152
+ # did not exist at T and is removed.
153
+ #
63
154
  # @param task_id [Integer] Target task ID
64
155
  # Made public for testing
65
156
  def restore_to_task_state(task_id)
66
- # Collect all modified files from task 1 to target task
67
- files_to_restore = {}
68
-
69
- (1..task_id).each do |tid|
70
- snapshot_dir = File.join(
71
- Dir.home,
72
- ".clacky",
73
- "snapshots",
74
- @session_id,
75
- "task-#{tid}"
76
- )
77
-
78
- next unless Dir.exist?(snapshot_dir)
79
-
80
- Dir.glob(File.join(snapshot_dir, "**", "*")).each do |snapshot_file|
81
- next if File.directory?(snapshot_file)
82
-
83
- relative_path = snapshot_file.sub(snapshot_dir + "/", "")
84
- files_to_restore[relative_path] = snapshot_file
85
- end
157
+ # Freeze the task we're leaving so a later forward switch can return.
158
+ checkpoint_latest_task_after
159
+
160
+ session_root = TimeMachine.session_dir(@session_id)
161
+
162
+ # Ancestor chain from the target task up to (and excluding) root 0,
163
+ # ordered nearest-first so the closest writer of each file wins.
164
+ ancestors = []
165
+ tid = task_id
166
+ until tid.nil? || tid <= 0 || ancestors.include?(tid)
167
+ ancestors << tid
168
+ tid = @task_parents[tid]
86
169
  end
87
-
88
- # Restore files
89
- files_to_restore.each do |relative_path, snapshot_file|
90
- target_file = File.join(@working_dir, relative_path)
91
- FileUtils.mkdir_p(File.dirname(target_file))
92
- FileUtils.cp(snapshot_file, target_file)
170
+
171
+ # Every file ever touched by any task in this session.
172
+ all_rels = Set.new
173
+ Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
174
+ next if File.directory?(path)
175
+
176
+ rel = path.sub(%r{\A.*/before/}, "")
177
+ rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
178
+ all_rels << rel
179
+ end
180
+
181
+ all_rels.each do |rel|
182
+ action = :delete
183
+ source = nil
184
+ matched = false
185
+
186
+ # Closest ancestor (starting at the target) that produced this file.
187
+ ancestors.each do |aid|
188
+ after_dir = File.join(session_root, "task-#{aid}", "after")
189
+ content_path = File.join(after_dir, rel)
190
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
191
+
192
+ if File.exist?(content_path)
193
+ action = :restore
194
+ source = content_path
195
+ matched = true
196
+ break
197
+ elsif File.exist?(absent_path)
198
+ action = :delete
199
+ matched = true
200
+ break
201
+ end
202
+ end
203
+
204
+ # No task on the chain produced this file. Restore the session's
205
+ # INITIAL content for it — captured as the earliest BEFORE recorded
206
+ # for this file by any task (BEFORE = state just before that task
207
+ # ran; the smallest task id therefore holds the pre-session state).
208
+ # No BEFORE at all => the file never existed initially, so delete.
209
+ unless matched
210
+ initial = earliest_before_snapshot(session_root, rel)
211
+ if initial
212
+ action = :restore
213
+ source = initial
214
+ else
215
+ action = :delete
216
+ end
217
+ end
218
+
219
+ target = File.join(@working_dir, rel)
220
+ if action == :delete
221
+ FileUtils.rm_f(target)
222
+ else
223
+ FileUtils.mkdir_p(File.dirname(target))
224
+ FileUtils.cp(source, target)
225
+ end
93
226
  end
94
- rescue StandardError => e
95
- # Silently handle errors in tests
227
+ rescue StandardError
96
228
  raise
97
229
  end
98
230
 
99
- # Filter messages to only show tasks up to active_task_id.
100
- # This hides "future" messages when user has undone.
101
- # Returns API-ready array (strips internal fields + handles orphaned tool_calls).
231
+ # The initial (pre-session) content path for a file, taken from the
232
+ # earliest BEFORE snapshot any task recorded for it. Returns the snapshot
233
+ # path to copy back, or nil if the earliest record is an absent marker
234
+ # (file did not exist at the session start).
235
+ private def earliest_before_snapshot(session_root, rel)
236
+ task_ids = Dir.glob(File.join(session_root, "task-*")).filter_map do |dir|
237
+ m = File.basename(dir).match(/\Atask-(\d+)\z/)
238
+ m && m[1].to_i
239
+ end.sort
240
+
241
+ task_ids.each do |tid|
242
+ before_dir = File.join(session_root, "task-#{tid}", "before")
243
+ content_path = File.join(before_dir, rel)
244
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
245
+ return content_path if File.exist?(content_path)
246
+ return nil if File.exist?(absent_path)
247
+ end
248
+ nil
249
+ end
250
+
251
+ # Relative path used to key a snapshot. Files inside the working dir keep
252
+ # their relative path; anything else falls back to its basename.
253
+ private def snapshot_relative_path(full_path)
254
+ if full_path.start_with?(@working_dir + "/")
255
+ full_path.sub(@working_dir + "/", "")
256
+ else
257
+ File.basename(full_path)
258
+ end
259
+ end
260
+
261
+ # Filter messages to only the active task's ancestor chain.
262
+ # After an undo (and especially after sending a NEW message post-undo,
263
+ # which forks a fresh task off the undone point) the history still holds
264
+ # the abandoned/sibling-branch turns. We must send the LLM only the turns
265
+ # on the path from the root to the active task — never undone siblings.
266
+ # Returns API-ready array (strips internal fields + repairs orphaned
267
+ # tool_calls), so this stays consistent with the normal to_api path.
102
268
  # @param force_reasoning_content_pad [Boolean] forwarded to MessageHistory,
103
269
  # enables one-shot pad-and-retry for thinking-mode providers that
104
270
  # require reasoning_content on every assistant message.
105
271
  # Made public for testing
106
272
  def active_messages(force_reasoning_content_pad: false)
107
- if @active_task_id == @current_task_id
108
- return @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
109
- end
273
+ @history.to_api(
274
+ force_reasoning_content_pad: force_reasoning_content_pad,
275
+ task_chain: active_task_chain
276
+ )
277
+ end
110
278
 
111
- stripped = @history.for_task(@active_task_id).map do |msg|
112
- msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
279
+ # The set of task IDs on the path from the root to @active_task_id,
280
+ # walked via @task_parents. Used to filter history so undone or
281
+ # sibling-branch turns are excluded from what the LLM sees. Task 0 is the
282
+ # root and is always included when reached (early turns are tagged 0).
283
+ private def active_task_chain
284
+ chain = Set.new
285
+ tid = @active_task_id
286
+ # Guard against a malformed parent map producing a cycle.
287
+ until tid.nil? || chain.include?(tid)
288
+ chain << tid
289
+ break if tid <= 0
290
+ tid = @task_parents[tid]
113
291
  end
114
- # Apply the same reasoning_content padding rule used by to_api so
115
- # Time Machine replays satisfy thinking-mode providers after a
116
- # 400 retry.
117
- MessageHistory.pad_reasoning_content_if_needed(stripped, force: force_reasoning_content_pad)
292
+ chain
118
293
  end
119
294
 
120
- # Undo to parent task
295
+ # Undo to parent task. Task 0 represents the original pre-task state,
296
+ # which is reachable from task 1 thanks to its BEFORE snapshots.
121
297
  def undo_last_task
298
+ return { success: false, message: "Already at root task" } if @active_task_id == 0
299
+
122
300
  parent_id = @task_parents[@active_task_id]
123
- return { success: false, message: "Already at root task" } if parent_id.nil? || parent_id == 0
301
+ return { success: false, message: "Already at root task" } if parent_id.nil?
124
302
 
125
303
  restore_to_task_state(parent_id)
126
304
  @active_task_id = parent_id
@@ -159,6 +337,8 @@ module Clacky
159
337
  def get_task_history(limit: 10)
160
338
  return [] if @current_task_id == 0
161
339
 
340
+ chain = active_task_chain
341
+
162
342
  tasks = []
163
343
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
164
344
  # Find first user message for this task
@@ -174,13 +354,15 @@ module Clacky
174
354
  "Task #{task_id}"
175
355
  end
176
356
 
177
- # Determine task status
357
+ # Status relative to the ACTIVE task chain (not a linear id compare),
358
+ # so undone/abandoned branches are flagged distinctly from the path
359
+ # the user is currently on.
178
360
  status = if task_id == @active_task_id
179
361
  :current
180
- elsif task_id < @active_task_id
362
+ elsif chain.include?(task_id)
181
363
  :past
182
364
  else
183
- :future
365
+ :undone
184
366
  end
185
367
 
186
368
  # Check if task has branches (multiple children)
@@ -254,6 +254,18 @@ module Clacky
254
254
  }
255
255
  end
256
256
 
257
+ # Show countdown before auto-executing in auto_approve mode.
258
+ # Gives the user time to see what's happening and Ctrl+C to cancel.
259
+ # @param seconds [Integer] Countdown duration
260
+ private def auto_approve_countdown(seconds: 10)
261
+ return unless @ui
262
+
263
+ seconds.downto(1) do |remaining|
264
+ @ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
265
+ sleep 1
266
+ end
267
+ end
268
+
257
269
  # Check if a tool is potentially slow and should show progress
258
270
  # @param tool_name [String] Name of the tool
259
271
  # @param args [Hash] Tool arguments
data/lib/clacky/agent.rb CHANGED
@@ -42,7 +42,7 @@ module Clacky
42
42
 
43
43
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
44
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
45
- :status, :error, :updated_at, :source,
45
+ :status, :error, :updated_at, :source, :config,
46
46
  :latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
47
47
  :reasoning_effort
48
48
  attr_accessor :pinned
@@ -102,7 +102,7 @@ module Clacky
102
102
  @ui = ui # UIController for direct UI interaction
103
103
  @debug_logs = [] # Debug logs for troubleshooting
104
104
  @pending_injections = [] # Pending inline skill injections to flush after observe()
105
- @pending_script_tmpdirs = [] # Decrypted-script tmpdirs to shred when agent.run completes
105
+ @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
106
106
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
107
107
  @last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
108
108
 
@@ -605,12 +605,6 @@ module Clacky
605
605
 
606
606
  result = build_result
607
607
 
608
- # Save snapshots of modified files for Time Machine
609
- if @modified_files_in_task && !@modified_files_in_task.empty?
610
- save_modified_files_snapshot(@modified_files_in_task)
611
- @modified_files_in_task = [] # Reset for next task
612
- end
613
-
614
608
  # Run skill evolution hooks after main loop completes
615
609
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
616
610
  # Only for main agent (not subagents) to avoid recursive evolution
@@ -677,11 +671,6 @@ module Clacky
677
671
  Clacky::Logger.warn("[ph_debug] agent_run_ensure")
678
672
  @ui&.show_progress(phase: "done")
679
673
 
680
- # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
681
- # This covers the inline-injection path; the subagent path shreds immediately after
682
- # subagent.run returns (see execute_skill_with_subagent).
683
- shred_script_tmpdirs
684
-
685
674
  # Fire-and-forget telemetry after every agent run.
686
675
  # Tracks daily active users (distinct devices per day) and task volume.
687
676
  Clacky::Telemetry.task!(result: result)
@@ -958,8 +947,11 @@ module Clacky
958
947
  end
959
948
  end
960
949
 
961
- # Special handling for request_user_feedback: don't show as tool call
962
- unless call[:name] == "request_user_feedback"
950
+ # Special handling for request_user_feedback
951
+ if call[:name] == "request_user_feedback"
952
+ # In auto_approve mode, give user time to see and cancel before auto-answering
953
+ auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
954
+ else
963
955
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
964
956
  end
965
957
 
@@ -1009,6 +1001,10 @@ module Clacky
1009
1001
  # instant tools like edit/write/read/glob/grep. Truly slow
1010
1002
  # tools (terminal running a build, web_fetch) exceed the
1011
1003
  # threshold and their final frame is preserved as usual.
1004
+ # Record BEFORE-change snapshots for Time Machine right before the
1005
+ # tool runs, so undo can restore (or delete) any file it touches.
1006
+ record_tool_target_before(call[:name], args)
1007
+
1012
1008
  result = nil
1013
1009
  if @ui
1014
1010
  progress_message = build_tool_progress_message(call[:name], args)
@@ -1023,9 +1019,6 @@ module Clacky
1023
1019
  result = tool.execute(**args)
1024
1020
  end
1025
1021
 
1026
- # Track modified files for Time Machine snapshots
1027
- track_modified_files(call[:name], args)
1028
-
1029
1022
  # Hook: after_tool_use
1030
1023
  @hooks.trigger(:after_tool_use, call, result)
1031
1024
 
@@ -1055,7 +1048,7 @@ module Clacky
1055
1048
  else
1056
1049
  # Use tool's format_result method to get display-friendly string
1057
1050
  formatted_result = tool.respond_to?(:format_result) ? tool.format_result(result) : result.to_s
1058
- @ui&.show_tool_result(formatted_result)
1051
+ @ui&.show_tool_result(redact_tool_args(formatted_result))
1059
1052
  end
1060
1053
 
1061
1054
  results << build_success_result(call, result)
@@ -1073,7 +1066,7 @@ module Clacky
1073
1066
  Clacky::Logger.error("tool_execution_error", tool: call[:name], error: e)
1074
1067
 
1075
1068
  @hooks.trigger(:on_tool_error, call, e)
1076
- @ui&.show_tool_error(e)
1069
+ @ui&.show_tool_error(redact_tool_args(e.message))
1077
1070
  # Use build_denied_result with system_injected=true so LLM knows it can retry
1078
1071
  results << build_denied_result(call, e.message, true)
1079
1072
  end
@@ -1176,8 +1169,8 @@ module Clacky
1176
1169
  end
1177
1170
 
1178
1171
  # Register a tmpdir that contains decrypted brand skill scripts.
1179
- # SkillManager calls this after decrypt_all_scripts so agent.run's ensure block
1180
- # can shred it when the run completes.
1172
+ # SkillManager calls this after decrypt_all_scripts. The tmpdir lives for
1173
+ # the agent's lifetime (a session), not just a single agent.run.
1181
1174
  # @param dir [String] Absolute path to the tmpdir
1182
1175
  def register_script_tmpdir(dir)
1183
1176
  @pending_script_tmpdirs << dir
@@ -1585,7 +1578,7 @@ module Clacky
1585
1578
 
1586
1579
  image = data_url ? { data_url: data_url } : { path: path }
1587
1580
 
1588
- @ui&.show_progress("OCR...", progress_type: "thinking", phase: "active")
1581
+ @ui&.show_progress("Reading image…", progress_type: "vision", phase: "active")
1589
1582
  begin
1590
1583
  Clacky::Vision::Resolver.new(ocr_entry).describe(image)
1591
1584
  ensure
@@ -1814,17 +1807,14 @@ module Clacky
1814
1807
  @ui&.show_assistant_message(full_content, files: parsed[:files])
1815
1808
  end
1816
1809
 
1817
- # Track modified files for Time Machine snapshots
1818
- # @param tool_name [String] Name of the tool that was executed
1810
+ # Record BEFORE-change snapshots for any file a tool is about to mutate,
1811
+ # so Time Machine can later restore or delete it.
1812
+ # @param tool_name [String] Name of the tool about to be executed
1819
1813
  # @param args [Hash] Arguments passed to the tool
1820
- def track_modified_files(tool_name, args)
1821
- @modified_files_in_task ||= []
1822
-
1814
+ private def record_tool_target_before(tool_name, args)
1823
1815
  case tool_name
1824
1816
  when "write", "edit"
1825
- file_path = args[:path]
1826
- full_path = File.expand_path(file_path, @working_dir)
1827
- @modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
1817
+ record_file_before_change(args[:path]) if args[:path]
1828
1818
  end
1829
1819
  end
1830
1820
  end
@@ -825,6 +825,24 @@ module Clacky
825
825
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
826
826
 
827
827
  if raw_entry && raw_entry["disabled"]
828
+ # A disabled OCR sidecar only means "no separate vision model"; it must
829
+ # not override the fact that the chat model may handle images itself.
830
+ anchor = current_model || default
831
+ anchor_provider = anchor && Clacky::Providers.resolve_provider(
832
+ base_url: anchor["base_url"], api_key: anchor["api_key"]
833
+ )
834
+ if anchor && anchor_provider &&
835
+ Clacky::Providers.supports?(anchor_provider, :vision, model_name: anchor["model"])
836
+ return {
837
+ "configured" => true,
838
+ "source" => "primary",
839
+ "model" => anchor["model"],
840
+ "base_url" => anchor["base_url"],
841
+ "provider" => anchor_provider,
842
+ "primary" => true,
843
+ "available" => available
844
+ }
845
+ end
828
846
  return {
829
847
  "configured" => false,
830
848
  "source" => "off",