openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "../agent_config"
5
+
6
+ module Clacky
7
+ module Server
8
+ # Shared directory policy for master lifecycle and CLI discovery.
9
+ module PidFiles
10
+ module_function
11
+
12
+ def directory
13
+ override = ENV["CLACKY_PID_DIR"]
14
+ configured = if override && !override.strip.empty?
15
+ override
16
+ else
17
+ AgentConfig.load.pid_dir
18
+ end
19
+ return Dir.tmpdir if configured.nil? || configured.strip.empty?
20
+
21
+ File.expand_path(configured)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "socket"
4
- require "tmpdir"
4
+ require_relative "pid_files"
5
5
  require_relative "../banner"
6
6
  require_relative "../version"
7
7
  require_relative "../agent_config"
@@ -248,13 +248,19 @@ module Clacky
248
248
  end
249
249
 
250
250
  def pid_file_path
251
- File.join(Dir.tmpdir, "clacky-master-#{@port}.pid")
251
+ File.join(pid_directory, "clacky-master-#{@port}.pid")
252
252
  end
253
253
 
254
254
  def write_pid_file
255
+ FileUtils.mkdir_p(pid_directory)
255
256
  File.write(pid_file_path, Process.pid.to_s)
256
257
  end
257
258
 
259
+ # Keep cleanup tied to the directory used at startup, even if config changes.
260
+ private def pid_directory
261
+ @pid_directory ||= PidFiles.directory
262
+ end
263
+
258
264
  def remove_pid_file
259
265
  # A replacement may already own the port after this master was stopped.
260
266
  if File.exist?(pid_file_path) && File.read(pid_file_path).strip == Process.pid.to_s
@@ -318,7 +324,7 @@ module Clacky
318
324
  end
319
325
 
320
326
  private def kill_master_on_port(port)
321
- path = File.join(Dir.tmpdir, "clacky-master-#{port}.pid")
327
+ path = File.join(pid_directory, "clacky-master-#{port}.pid")
322
328
  return unless File.exist?(path)
323
329
 
324
330
  pid = File.read(path).strip.to_i
@@ -194,7 +194,8 @@ module Clacky
194
194
  # profile: "general"|"coding"|nil
195
195
  # nil = no agent_profile filter
196
196
  # limit: max sessions to return (applies to NON-PINNED only; see below)
197
- # before: ISO8601 cursor — only sessions with created_at < before
197
+ # before, before_id: cursor — only sessions sorting after (before, before_id)
198
+ # in the newest-first order; take it from a row's `cursor`
198
199
  # (also applies to NON-PINNED only; pinned items are a separate
199
200
  # logical section, they should never be paginated away)
200
201
  # include_pinned: when true (default), all matching pinned sessions are
@@ -204,11 +205,16 @@ module Clacky
204
205
  # an old session always see it at the top of the sidebar,
205
206
  # even if many newer sessions exist.
206
207
  #
208
+ # archived: false (default) → archived sessions are excluded from the
209
+ # result; true → ONLY archived sessions are returned. The two
210
+ # sets are disjoint, so every other caller keeps seeing exactly
211
+ # the sessions it saw before archiving existed. nil → no filter.
212
+ #
207
213
  # Ordering of the returned array:
208
214
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
209
215
  #
210
216
  # source and profile are orthogonal — either can be nil independently.
211
- def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false)
217
+ def list(limit: nil, before: nil, before_id: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, archived: false)
212
218
  return [] unless @session_manager
213
219
 
214
220
  live = @mutex.synchronize do
@@ -222,6 +228,7 @@ module Clacky
222
228
  model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
223
229
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
224
230
  cost_source: live_cost_source,
231
+ permission_mode: s[:agent]&.permission_mode,
225
232
  reasoning_effort: s[:agent]&.reasoning_effort,
226
233
  latest_latency: s[:agent]&.latest_latency,
227
234
  card_model: model_info&.dig(:card_model),
@@ -233,6 +240,8 @@ module Clacky
233
240
 
234
241
  all = @session_manager.all_sessions # already sorted newest-first
235
242
 
243
+ all = all.select { |s| archived ? s[:archived_at].to_s != "" : s[:archived_at].to_s == "" } unless archived.nil?
244
+
236
245
  # ── type filter (replaces old source/profile split) ──────────────────
237
246
  # type=coding → agent_profile == "coding"
238
247
  # type=manual/cron/channel/setup → source match (profile=general implied)
@@ -290,9 +299,11 @@ module Clacky
290
299
  pinned, non_pinned = all.partition { |s| s[:pinned] }
291
300
 
292
301
  # `before` cursor ONLY applies to non-pinned (paginated) sessions.
293
- # Cursor field must match the sort key in all_sessions (updated_at,
294
- # falling back to created_at for legacy rows).
295
- non_pinned = non_pinned.select { |s| (s[:updated_at] || s[:created_at] || "") < before } if before
302
+ # Cursor must match the sort key in all_sessions; the id breaks same-second ties.
303
+ if before
304
+ boundary = [before, before_id.to_s]
305
+ non_pinned = non_pinned.select { |s| (sort_key(s) <=> boundary) < 0 }
306
+ end
296
307
  non_pinned = non_pinned.first(limit) if limit
297
308
 
298
309
  # Pinned section: only included on the first page (before == nil) so
@@ -336,6 +347,7 @@ module Clacky
336
347
  model: model_info&.dig(:model), model_id: model_info&.dig(:id),
337
348
  name: live_name, total_tasks: s[:agent]&.total_tasks,
338
349
  total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
350
+ permission_mode: s[:agent]&.permission_mode,
339
351
  reasoning_effort: s[:agent]&.reasoning_effort,
340
352
  latest_latency: s[:agent]&.latest_latency,
341
353
  card_model: model_info&.dig(:card_model),
@@ -354,6 +366,10 @@ module Clacky
354
366
  # Merge a single disk-side session hash with the corresponding live
355
367
  # in-memory agent fields (may be nil) into the row shape the frontend
356
368
  # consumes.
369
+ private def sort_key(s)
370
+ [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s]
371
+ end
372
+
357
373
  private def build_enriched_row(s, ls)
358
374
  id = s[:session_id]
359
375
  {
@@ -366,6 +382,7 @@ module Clacky
366
382
  raw_message: ls&.dig(:raw_message),
367
383
  model: ls&.dig(:model),
368
384
  model_id: ls&.dig(:model_id),
385
+ permission_mode: ls&.dig(:permission_mode) || s.dig(:config, :permission_mode_override) || "confirm_all",
369
386
  card_model: ls&.dig(:card_model),
370
387
  sub_model: ls&.dig(:sub_model),
371
388
  sub_model_options: ls&.dig(:sub_model_options) || [],
@@ -384,9 +401,12 @@ module Clacky
384
401
  latest_latency: ls&.dig(:latest_latency),
385
402
  reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
386
403
  pinned: s[:pinned] || false,
404
+ archived_at: s[:archived_at],
387
405
  channel_info: s[:channel_info],
388
406
  project_id: s[:project_id],
389
407
  todos: ls&.dig(:todos) || s[:todos] || [],
408
+ # Rows report live activity in updated_at; paging needs the on-disk sort key.
409
+ cursor: sort_key(s).then { |at, sid| { before: at, before_id: sid } },
390
410
  }
391
411
  end
392
412
 
@@ -414,10 +434,13 @@ module Clacky
414
434
  public
415
435
 
416
436
  # Count + latest updated_at for sessions of a given source, excluding
417
- # those assigned to a project (they show in the project section instead).
437
+ # those assigned to a project (they show in the project section instead)
438
+ # and archived ones (they show in the archive view instead).
418
439
  def group_stats(source)
419
440
  return { count: 0, latest_updated_at: nil } unless @session_manager
420
- group_all = @session_manager.all_sessions.select { |s| s_source(s) == source && s[:project_id].to_s.strip == "" }
441
+ group_all = @session_manager.all_sessions.select {
442
+ |s| s_source(s) == source && s[:project_id].to_s.strip == "" && s[:archived_at].to_s == ""
443
+ }
421
444
  latest = group_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
422
445
  { count: group_all.size, latest_updated_at: latest }
423
446
  end
@@ -427,6 +450,13 @@ module Clacky
427
450
  SessionManager::GROUPED_SOURCES.to_h { |source| [source, group_stats(source)] }
428
451
  end
429
452
 
453
+ # How many sessions the archive holds. The archive page keeps only one
454
+ # page of rows in the browser, so its "delete all" confirmation takes the
455
+ # size from here — a header-only scan, unlike `list`.
456
+ def archived_count
457
+ @session_manager ? @session_manager.archived_count : 0
458
+ end
459
+
430
460
  # Delete a session from registry (and interrupt its thread).
431
461
  def delete(session_id)
432
462
  @mutex.synchronize do
@@ -68,6 +68,71 @@ module Clacky
68
68
  end
69
69
  end
70
70
 
71
+ # Use the same contract as the CLI. The server owns the deadline;
72
+ # typing cancels automatic continuation but keeps the answer wait alive.
73
+ def request_feedback_with_countdown(seconds: 20)
74
+ pending = { id: @feedback_request_id || SecureRandom.uuid, cond: ConditionVariable.new,
75
+ deadline_ms: (Time.now.to_f * 1000).to_i + seconds * 1000 }
76
+ pending[:seconds] = seconds
77
+ pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
78
+ @mutex.synchronize do
79
+ pending[:manual] = @feedback_permission_mode == :confirm_all
80
+ @pending_feedback = pending
81
+ end
82
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms])
83
+ @mutex.synchronize do
84
+ until pending[:result]
85
+ remaining = pending[:deadline] - Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ if !pending[:manual] && remaining <= 0
87
+ pending[:result] = :timeout
88
+ else
89
+ pending[:cond].wait(@mutex, pending[:manual] ? nil : remaining)
90
+ end
91
+ end
92
+ pending[:result]
93
+ end
94
+ ensure
95
+ cleared = @mutex.synchronize do
96
+ if pending && @pending_feedback.equal?(pending)
97
+ @pending_feedback = nil
98
+ true
99
+ end
100
+ end
101
+ if cleared
102
+ emit("feedback_countdown", request_id: pending[:id], resolved: true,
103
+ resolution: pending[:result] || :cancelled)
104
+ end
105
+ end
106
+
107
+ def update_permission_mode(mode)
108
+ countdown = @mutex.synchronize do
109
+ @feedback_permission_mode = mode.to_sym
110
+ pending = @pending_feedback
111
+ if pending && !pending[:result]
112
+ if mode.to_sym == :auto_approve && pending[:manual]
113
+ pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pending[:seconds]
114
+ pending[:deadline_ms] = (Time.now.to_f * 1000).to_i + pending[:seconds] * 1000
115
+ end
116
+ pending[:manual] = mode.to_sym != :auto_approve
117
+ pending[:cond].signal
118
+ { request_id: pending[:id],
119
+ deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] }
120
+ end
121
+ end
122
+ emit("feedback_countdown", **countdown) if countdown
123
+ end
124
+
125
+ def intervene_feedback(request_id)
126
+ @mutex.synchronize do
127
+ pending = @pending_feedback
128
+ return false unless pending && pending[:id] == request_id && !pending[:result]
129
+ pending[:result] = :intervened
130
+ pending[:cond].signal
131
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: nil)
132
+ true
133
+ end
134
+ end
135
+
71
136
  # === Output display ===
72
137
 
73
138
  def show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil, steering: false, references: [])
@@ -129,7 +194,9 @@ module Clacky
129
194
  context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
130
195
  first = questions.first || {}
131
196
 
197
+ @feedback_request_id = Clacky::Tools::AskUser.fetch_key(args_data, :request_id)
132
198
  emit("request_feedback",
199
+ request_id: @feedback_request_id,
133
200
  question: first[:question].to_s,
134
201
  context: context,
135
202
  options: first[:options] || [],
@@ -336,6 +403,11 @@ module Clacky
336
403
  # The frontend's appendToolStdout will attach to the last visible .tool-item
337
404
  # even when _liveLastToolItem is null (after the tab re-loaded).
338
405
  def replay_live_state
406
+ countdown = @mutex.synchronize do
407
+ pending = @pending_feedback
408
+ pending ? { request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] } : { resolved: true }
409
+ end
410
+ emit("feedback_countdown", **countdown)
339
411
  return unless @live_progress_state
340
412
 
341
413
  # Replay complete progress state (not just message).
@@ -216,7 +216,7 @@ module Clacky
216
216
  def all_sessions(current_dir: nil, limit: nil)
217
217
  sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
218
218
  load_session_file(filepath)
219
- end.sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
219
+ end.sort_by { |s| [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s] }.reverse
220
220
 
221
221
  if current_dir
222
222
  current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
@@ -363,7 +363,10 @@ module Clacky
363
363
  metas = files.filter_map { |f| m = load_session_metadata(f); m && m.merge(_path: f) }
364
364
  .sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
365
365
 
366
- non_pinned = metas.reject { |s| s[:pinned] }
366
+ # Archived sessions are user-curated, so they are exempt from the rolling
367
+ # cap exactly like pinned ones — an archive that silently empties itself
368
+ # would be worse than no archive at all.
369
+ non_pinned = metas.reject { |s| s[:pinned] || s[:archived_at].to_s != "" }
367
370
 
368
371
  groups = non_pinned.group_by { |s| group_key_for(s) }
369
372
 
@@ -433,6 +436,12 @@ module Clacky
433
436
  Clacky::Tools::TrashManager.permanent_delete_trash_session(session_id, sessions_dir: @sessions_dir)
434
437
  end
435
438
 
439
+ # Permanently delete exactly these sessions from the trash. Returns the count deleted.
440
+ def permanent_delete_trash_sessions(session_ids)
441
+ require_relative "tools/trash_manager"
442
+ Clacky::Tools::TrashManager.empty_trash_sessions(sessions_dir: @sessions_dir, ids: session_ids)
443
+ end
444
+
436
445
  # Keep file-trash on the same rolling window as sessions-trash.
437
446
  def cleanup_file_trash(days: 8)
438
447
  require_relative "tools/trash_manager"
@@ -547,10 +556,11 @@ module Clacky
547
556
  # Read only the small scalar header of a session file without parsing the
548
557
  # (potentially multi-MB) messages / time_machine payload. Session JSON is
549
558
  # written with a stable field order, so the fields we care about here
550
- # (session_id, pinned, created_at, updated_at, source) always sit in the
551
- # first few hundred bytes, before any large array/object. `project_id`
552
- # lives much later and is intentionally NOT read — callers that need it
553
- # fall back to load_session_file for the rare candidate that requires it.
559
+ # (session_id, pinned, archived_at, created_at, updated_at, source) always
560
+ # sit in the first few hundred bytes, before any large array/object.
561
+ # `project_id` lives much later and is intentionally NOT read — callers
562
+ # that need it fall back to load_session_file for the rare candidate that
563
+ # requires it.
554
564
  #
555
565
  # Returns a Hash with symbol keys, or nil if the header can't be read.
556
566
  METADATA_HEADER_BYTES = 16 * 1024
@@ -564,6 +574,7 @@ module Clacky
564
574
  {
565
575
  session_id: sid,
566
576
  pinned: head[/"pinned"\s*:\s*(true|false)/, 1] == "true",
577
+ archived_at: head[/"archived_at"\s*:\s*"([^"]*)"/, 1],
567
578
  created_at: head[/"created_at"\s*:\s*"([^"]*)"/, 1],
568
579
  updated_at: head[/"updated_at"\s*:\s*"([^"]*)"/, 1],
569
580
  source: head[/"source"\s*:\s*"([^"]*)"/, 1]
@@ -572,6 +583,17 @@ module Clacky
572
583
  nil
573
584
  end
574
585
 
586
+ # How many sessions sit in the archive. The WebUI offers "delete all" on
587
+ # that page, so it needs the size of the whole archive rather than the page
588
+ # it happens to have loaded — and a header-only read keeps the cost at one
589
+ # small read per file.
590
+ def archived_count
591
+ Dir.glob(File.join(@sessions_dir, "*.json")).count do |filepath|
592
+ meta = load_session_metadata(filepath)
593
+ meta && meta[:archived_at].to_s != ""
594
+ end
595
+ end
596
+
575
597
  # Remove Time Machine snapshots that no longer belong to any known session.
576
598
  # Snapshots are keyed by full session_id; session files are named by the
577
599
  # 8-char id prefix, so a snapshot dir is an orphan when its prefix matches
data/lib/clacky/skill.rb CHANGED
@@ -319,7 +319,7 @@ module Clacky
319
319
  > should either:
320
320
  >
321
321
  > 1. Ask the user to start the server in another terminal: `clacky server`
322
- > (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
322
+ > (then retry — the CLI auto-detects it via `clacky-master-*.pid` in the configured PID directory), or
323
323
  > 2. If the task can be completed without the server API, skip those steps
324
324
  > and tell the user which parts require the server.
325
325
  >
@@ -110,7 +110,7 @@ module Clacky
110
110
  recommended = recommended.to_i if recommended.is_a?(Numeric) || recommended.to_s =~ /\A-?\d+\z/
111
111
  recommended = nil unless recommended.is_a?(Integer) && recommended >= 0 && recommended < options.size
112
112
 
113
- {
113
+ normalized = {
114
114
  question: text,
115
115
  description: fetch_key(item, :description).to_s.strip,
116
116
  options: options,
@@ -119,6 +119,9 @@ module Clacky
119
119
  allow_free_text: options.empty? || truthy?(fetch_key(item, :allow_free_text)),
120
120
  recommended: recommended
121
121
  }
122
+ metadata = fetch_key(item, :metadata)
123
+ normalized[:metadata] = metadata if metadata.is_a?(Hash)
124
+ normalized
122
125
  end
123
126
 
124
127
  def self.fetch_key(hash, key)
@@ -14,8 +14,9 @@ module Clacky
14
14
  attr_accessor :receives_tool_call_id
15
15
  end
16
16
 
17
- # Host agent, injected at registration for extension tools so they can
18
- # reach its public API (fan_out_labeled, fork_subagent, skill_loader).
17
+ # Host agent, injected at registration for extension tools and for built-ins
18
+ # that need its public API (config, fan_out_labeled, fork_subagent,
19
+ # skill_loader).
19
20
  attr_accessor :agent
20
21
 
21
22
  def name
@@ -543,12 +543,21 @@ module Clacky
543
543
  # Save original (full-resolution) PNG to disk before any downscaling
544
544
  original_path = save_screenshot_to_disk(image_block["data"], suffix: "original")
545
545
 
546
+ config = screenshot_config
547
+ limit = config["screenshot_max_base64_bytes"]
548
+ max_base64_bytes = limit.is_a?(Integer) && limit.positive? ? limit : SCREENSHOT_MAX_BASE64_BYTES
546
549
  image_data = png_downscale_base64(image_block["data"], SCREENSHOT_MAX_WIDTH)
550
+ if image_data.bytesize > max_base64_bytes
551
+ image_data = Clacky::Utils::FileProcessor.quantize_png_base64(image_data)
552
+ end
547
553
 
548
- if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
554
+ if image_data.bytesize > max_base64_bytes
549
555
  size_kb = image_data.bytesize / 1024
556
+ saved = original_path ? " The full-resolution file is saved at #{original_path}." : ""
550
557
  return { action: "screenshot", success: false, profile: "user",
551
- output: "Screenshot too large after resize (#{size_kb}KB). Use action=snapshot instead." }
558
+ original_path: original_path,
559
+ output: "Screenshot still too large after compression (#{size_kb}KB).#{saved} " \
560
+ "Use action=snapshot instead." }
552
561
  end
553
562
 
554
563
  # Save compressed (800px) PNG for AI reference
@@ -560,6 +569,13 @@ module Clacky
560
569
  output: "Screenshot captured." }
561
570
  end
562
571
 
572
+ private def screenshot_config
573
+ config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
574
+ config.is_a?(Hash) ? config : {}
575
+ rescue StandardError
576
+ {}
577
+ end
578
+
563
579
  private def png_downscale_base64(b64, max_width)
564
580
  Clacky::Utils::FileProcessor.downscale_image_base64(
565
581
  b64, "image/png", max_width: max_width
@@ -46,7 +46,7 @@ module Clacky
46
46
  MAX_CONTENT_CHARS = 60_000
47
47
 
48
48
  # Maximum characters per line (prevent single huge lines from bloating tokens)
49
- MAX_LINE_CHARS = 1000
49
+ MAX_LINE_CHARS = 8_000
50
50
 
51
51
  def execute(path:, max_lines: 1000, start_line: nil, end_line: nil, working_dir: nil, image_max_width: nil)
52
52
  # Expand path relative to working_dir when provided
@@ -43,7 +43,9 @@ module Clacky
43
43
  :read_offset, # Integer, bytes already returned by previous read calls
44
44
  :mutex, # per-session mutex for PTY writes
45
45
  :resource_group, # Optional cgroup for task limits and OOM attribution
46
+ :script_paths, # Internal script paths for cleanup and output masking
46
47
  :shell_name, # "zsh" | "bash" | "sh" — informs marker syntax & rc reload
48
+ :echo_head, # String, last `{ CMD` line written; strips its wrapped echo
47
49
  keyword_init: true
48
50
  )
49
51
 
@@ -77,6 +79,7 @@ module Clacky
77
79
  read_offset: 0,
78
80
  mutex: Mutex.new,
79
81
  resource_group: resource_group,
82
+ script_paths: [],
80
83
  shell_name: shell_name
81
84
  )
82
85
  @sessions[session.id] = session
@@ -116,7 +119,21 @@ module Clacky
116
119
  # Forget a session (after it has been killed/exited). Does NOT kill
117
120
  # the process — callers should kill first.
118
121
  def forget(id)
119
- @mutex.synchronize { @sessions.delete(id) }
122
+ session = @mutex.synchronize { @sessions.delete(id) }
123
+ cleanup_scripts(session) if session
124
+ end
125
+
126
+ # Cleanup must never replace the command's result with an unlink error.
127
+ def cleanup_scripts(session)
128
+ # The reader may clean up before buffered output is packaged.
129
+ # Preserve paths so error messages can still mask internal filenames.
130
+ Array(session.script_paths).each do |path|
131
+ begin
132
+ File.unlink(path)
133
+ rescue SystemCallError
134
+ # Best effort; retain the path for a later cleanup attempt.
135
+ end
136
+ end
120
137
  end
121
138
 
122
139
  # Refresh status of one session in-place (mutex-held).
@@ -188,6 +205,7 @@ module Clacky
188
205
  s.log_io&.close rescue nil
189
206
  s.writer&.close rescue nil
190
207
  s.reader&.close rescue nil
208
+ cleanup_scripts(s)
191
209
  end
192
210
  end
193
211