openclacky 1.3.2 → 1.3.4

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -41,22 +41,39 @@ module Clacky
41
41
  @task_parents ||= {} # { task_id => parent_id }
42
42
  @current_task_id ||= 0 # Latest created task ID
43
43
  @active_task_id ||= 0 # Current active task ID (for undo/redo)
44
+ @task_meta ||= {} # { task_id => { title:, started_at:, ended_at: } }
45
+ @latest_after_dirty = false if @latest_after_dirty.nil?
44
46
  end
45
47
 
46
48
  # Start a new task and establish parent relationship
49
+ # @param title [String, nil] Short label for this turn (typically the
50
+ # user's first message, truncated). Used by the UI to label snapshots
51
+ # even after the original conversation has been compressed out of
52
+ # @history. nil → leave unset; the UI falls back to "Task N".
47
53
  # Made public for testing
48
- def start_new_task
54
+ def start_new_task(title: nil)
49
55
  # Before the currently-active task stops being the latest, freeze its
50
56
  # end-of-task disk state into an AFTER snapshot. Without this, a task
51
57
  # that later gets superseded by a sibling branch would have no record
52
58
  # of its result, making a forward switch back to it impossible.
53
59
  checkpoint_latest_task_after
54
60
 
61
+ # Close out the task we're leaving.
62
+ if @active_task_id.to_i > 0 && @task_meta[@active_task_id]
63
+ @task_meta[@active_task_id][:ended_at] ||= Time.now.to_f
64
+ end
65
+
55
66
  parent_id = @active_task_id
56
67
  @current_task_id += 1
57
68
  @active_task_id = @current_task_id
58
69
  @task_parents[@current_task_id] = parent_id
59
70
 
71
+ @task_meta[@current_task_id] = {
72
+ title: title ? truncate_task_title(title) : nil,
73
+ started_at: Time.now.to_f,
74
+ ended_at: nil,
75
+ }
76
+
60
77
  # Claim ownership of this task for the current thread.
61
78
  # If a stale thread (e.g. a slow subagent) wakes up later it will see
62
79
  # @task_thread != Thread.current via check_stale! and self-terminate
@@ -68,6 +85,21 @@ module Clacky
68
85
  @current_task_id
69
86
  end
70
87
 
88
+ # Update the title of the currently-active task. Used by callers that
89
+ # only learn the user-facing label after start_new_task has run.
90
+ def set_current_task_title(title)
91
+ return if @active_task_id.to_i <= 0
92
+ @task_meta[@active_task_id] ||= { started_at: Time.now.to_f, ended_at: nil }
93
+ @task_meta[@active_task_id][:title] = truncate_task_title(title)
94
+ end
95
+
96
+ private def truncate_task_title(text)
97
+ s = text.to_s
98
+ # Collapse whitespace so multi-line inputs render as a single label.
99
+ s = s.gsub(/\s+/, " ").strip
100
+ s.length > 60 ? "#{s[0...57]}..." : s
101
+ end
102
+
71
103
  # Record a file's BEFORE state for the current task, the first time the
72
104
  # task touches it. Call this immediately before a tool mutates the file.
73
105
  # Subsequent calls within the same task are no-ops so the earliest state
@@ -157,10 +189,27 @@ module Clacky
157
189
  # Freeze the task we're leaving so a later forward switch can return.
158
190
  checkpoint_latest_task_after
159
191
 
192
+ plan = build_restore_plan(task_id)
193
+ plan.each do |rel, decision|
194
+ target = File.join(@working_dir, rel)
195
+ if decision[:action] == :delete
196
+ FileUtils.rm_f(target)
197
+ else
198
+ FileUtils.mkdir_p(File.dirname(target))
199
+ FileUtils.cp(decision[:source], target)
200
+ end
201
+ end
202
+ rescue StandardError
203
+ raise
204
+ end
205
+
206
+ # Decide, for every file the session has ever touched, whether restoring
207
+ # to `task_id` should overwrite it with a snapshot or delete it. Pure
208
+ # function over the snapshot tree — does not touch the working dir.
209
+ # @return [Hash{String => Hash}] rel_path => { action: :delete | :restore, source: String|nil }
210
+ private def build_restore_plan(task_id)
160
211
  session_root = TimeMachine.session_dir(@session_id)
161
212
 
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
213
  ancestors = []
165
214
  tid = task_id
166
215
  until tid.nil? || tid <= 0 || ancestors.include?(tid)
@@ -168,22 +217,20 @@ module Clacky
168
217
  tid = @task_parents[tid]
169
218
  end
170
219
 
171
- # Every file ever touched by any task in this session.
172
220
  all_rels = Set.new
173
221
  Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
174
222
  next if File.directory?(path)
175
-
176
223
  rel = path.sub(%r{\A.*/before/}, "")
177
224
  rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
178
225
  all_rels << rel
179
226
  end
180
227
 
228
+ plan = {}
181
229
  all_rels.each do |rel|
182
230
  action = :delete
183
231
  source = nil
184
232
  matched = false
185
233
 
186
- # Closest ancestor (starting at the target) that produced this file.
187
234
  ancestors.each do |aid|
188
235
  after_dir = File.join(session_root, "task-#{aid}", "after")
189
236
  content_path = File.join(after_dir, rel)
@@ -201,11 +248,6 @@ module Clacky
201
248
  end
202
249
  end
203
250
 
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
251
  unless matched
210
252
  initial = earliest_before_snapshot(session_root, rel)
211
253
  if initial
@@ -216,16 +258,49 @@ module Clacky
216
258
  end
217
259
  end
218
260
 
261
+ plan[rel] = { action: action, source: source }
262
+ end
263
+
264
+ plan
265
+ end
266
+
267
+ # Preview the file-level effect of restore_to_task_state(task_id) without
268
+ # touching disk. Compares the resolved restore plan against the current
269
+ # working-dir state and returns only files that would actually change.
270
+ # @return [Array<Hash>] [{ path:, action: "create"|"modify"|"delete" }]
271
+ def preview_restore_to_task(task_id)
272
+ return [] unless task_id.is_a?(Integer) && task_id >= 0
273
+
274
+ checkpoint_latest_task_after
275
+ plan = build_restore_plan(task_id)
276
+ changes = []
277
+
278
+ plan.each do |rel, decision|
219
279
  target = File.join(@working_dir, rel)
220
- if action == :delete
221
- FileUtils.rm_f(target)
280
+ target_exists = File.exist?(target)
281
+
282
+ if decision[:action] == :delete
283
+ changes << { path: rel, action: "delete" } if target_exists
222
284
  else
223
- FileUtils.mkdir_p(File.dirname(target))
224
- FileUtils.cp(source, target)
285
+ src = decision[:source]
286
+ next unless src && File.exist?(src)
287
+
288
+ if !target_exists
289
+ changes << { path: rel, action: "create" }
290
+ elsif !files_equal?(src, target)
291
+ changes << { path: rel, action: "modify" }
292
+ end
225
293
  end
226
294
  end
295
+
296
+ changes.sort_by { |c| c[:path] }
297
+ end
298
+
299
+ private def files_equal?(a, b)
300
+ return false unless File.size(a) == File.size(b)
301
+ File.binread(a) == File.binread(b)
227
302
  rescue StandardError
228
- raise
303
+ false
229
304
  end
230
305
 
231
306
  # The initial (pre-session) content path for a file, taken from the
@@ -331,6 +406,143 @@ module Clacky
331
406
  @task_parents.select { |_, parent| parent == task_id }.keys
332
407
  end
333
408
 
409
+ # Cheap version of task_diff_files: just count how many distinct files
410
+ # this task touched, so the timeline can grey out no-op tasks without
411
+ # paying for a full diff walk per row.
412
+ def task_change_count(task_id)
413
+ return 0 unless task_id.is_a?(Integer) && task_id > 0
414
+
415
+ session_root = TimeMachine.session_dir(@session_id)
416
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
417
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
418
+ return 0 unless Dir.exist?(before_dir)
419
+ return 0 if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
420
+
421
+ rels = Set.new
422
+ [before_dir, after_dir].each do |root|
423
+ next unless Dir.exist?(root)
424
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
425
+ next if File.directory?(path)
426
+ rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
427
+ rels << rel
428
+ end
429
+ end
430
+ rels.size
431
+ end
432
+
433
+ # File-level summary of changes a task introduced. Diff is task-N/before
434
+ # vs task-N/after (after is captured by checkpoint_latest_task_after when
435
+ # the task stops being the latest, so this method has no useful answer
436
+ # for the currently-active task — callers get an empty list back).
437
+ # @return [Array<Hash>] Each entry: { path:, status: "added"|"modified"|"deleted", binary: Bool }
438
+ def task_diff_files(task_id)
439
+ return [] unless task_id.is_a?(Integer) && task_id > 0
440
+
441
+ session_root = TimeMachine.session_dir(@session_id)
442
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
443
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
444
+ return [] unless Dir.exist?(before_dir)
445
+ return [] if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
446
+
447
+ rels = Set.new
448
+ [before_dir, after_dir].each do |root|
449
+ next unless Dir.exist?(root)
450
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
451
+ next if File.directory?(path)
452
+ rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
453
+ rels << rel
454
+ end
455
+ end
456
+
457
+ rels.sort.map do |rel|
458
+ before_file, before_absent = snapshot_paths(before_dir, rel)
459
+ after_file, after_absent = snapshot_paths(after_dir, rel)
460
+
461
+ status = if before_absent && after_file
462
+ "added"
463
+ elsif before_file && after_absent
464
+ "deleted"
465
+ elsif before_file && after_file
466
+ "modified"
467
+ elsif before_file && !File.exist?(after_dir)
468
+ # No AFTER captured (e.g. the very latest task) — still surface
469
+ # what was touched as "modified" so the UI can list the file.
470
+ "modified"
471
+ else
472
+ "modified"
473
+ end
474
+
475
+ binary = looks_binary?(before_file) || looks_binary?(after_file)
476
+ { path: rel, status: status, binary: binary }
477
+ end
478
+ end
479
+
480
+ # Unified diff of a single file for a task. Returns nil if either side
481
+ # is missing or binary. text format = "@@ ... @@" patch (3-context),
482
+ # ready for the UI to render with a diff renderer.
483
+ # @return [Hash, nil] { path:, before:, after:, patch:, binary: }
484
+ def task_file_diff(task_id, rel_path)
485
+ return nil unless task_id.is_a?(Integer) && task_id > 0
486
+ return nil if rel_path.to_s.include?("..")
487
+
488
+ session_root = TimeMachine.session_dir(@session_id)
489
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
490
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
491
+
492
+ before_file, before_absent = snapshot_paths(before_dir, rel_path)
493
+ after_file, after_absent = snapshot_paths(after_dir, rel_path)
494
+
495
+ before_text = before_absent ? "" : (before_file ? read_text_safe(before_file) : nil)
496
+ after_text = after_absent ? "" : (after_file ? read_text_safe(after_file) : nil)
497
+
498
+ if before_text.nil? && after_text.nil?
499
+ return nil
500
+ end
501
+
502
+ # Detect binary on either side: bail out, the UI will render a stub.
503
+ if (before_file && looks_binary?(before_file)) || (after_file && looks_binary?(after_file))
504
+ return { path: rel_path, before: nil, after: nil, patch: nil, binary: true }
505
+ end
506
+
507
+ require "diffy" unless defined?(Diffy)
508
+ raw = Diffy::Diff.new(before_text || "", after_text || "",
509
+ context: 3, include_diff_info: true).to_s(:text)
510
+ # Strip Diffy's "--- /tmp/diffy.../before" header pair: it leaks
511
+ # tempfile paths and adds noise the UI doesn't need.
512
+ patch = raw.sub(/\A(?:---[^\n]*\n[^\n]*\n)/, "")
513
+
514
+ { path: rel_path, before: before_text, after: after_text, patch: patch, binary: false }
515
+ end
516
+
517
+ private def snapshot_paths(dir, rel)
518
+ content_path = File.join(dir, rel)
519
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
520
+ if File.exist?(content_path)
521
+ [content_path, false]
522
+ elsif File.exist?(absent_path)
523
+ [nil, true]
524
+ else
525
+ [nil, false]
526
+ end
527
+ end
528
+
529
+ private def looks_binary?(path)
530
+ return false if path.nil? || !File.exist?(path)
531
+ sample = File.binread(path, 8000)
532
+ sample.include?("\x00") || !sample.dup.force_encoding("UTF-8").valid_encoding?
533
+ rescue StandardError
534
+ true
535
+ end
536
+
537
+ private def read_text_safe(path)
538
+ File.read(path, mode: "rb").then do |s|
539
+ s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
540
+ s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
541
+ end
542
+ rescue StandardError
543
+ ""
544
+ end
545
+
334
546
  # Get task history with summaries for UI display
335
547
  # @param limit [Integer] Maximum number of recent tasks to return
336
548
  # @return [Array<Hash>] Task history with metadata
@@ -341,17 +553,23 @@ module Clacky
341
553
 
342
554
  tasks = []
343
555
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
344
- # Find first user message for this task
345
- first_user_msg = @history.to_a.find do |msg|
346
- msg[:task_id] == task_id && msg[:role] == "user"
347
- end
556
+ meta = (@task_meta || {})[task_id] || {}
348
557
 
349
- summary = if first_user_msg
350
- content = extract_message_text(first_user_msg[:content])
351
- # Truncate to 60 characters (including "...")
352
- content.length > 60 ? "#{content[0...57]}..." : content
558
+ summary = if meta[:title] && !meta[:title].to_s.empty?
559
+ meta[:title]
353
560
  else
354
- "Task #{task_id}"
561
+ # Best-effort fallback: scan @history for the task's first real
562
+ # user message. Returns nothing for tasks that have already been
563
+ # compressed out — the UI then shows "Task N".
564
+ first = @history.to_a.find do |msg|
565
+ msg[:role] == "user" && msg[:task_id] == task_id && !msg[:system_injected]
566
+ end
567
+ if first
568
+ text = extract_message_text(first[:content]).to_s.gsub(/\s+/, " ").strip
569
+ text.length > 60 ? "#{text[0...57]}..." : text
570
+ else
571
+ "Task #{task_id}"
572
+ end
355
573
  end
356
574
 
357
575
  # Status relative to the ACTIVE task chain (not a linear id compare),
@@ -372,8 +590,11 @@ module Clacky
372
590
  tasks << {
373
591
  task_id: task_id,
374
592
  summary: summary,
593
+ started_at: meta[:started_at],
594
+ ended_at: meta[:ended_at],
375
595
  status: status,
376
- has_branches: has_branches
596
+ has_branches: has_branches,
597
+ change_count: task_change_count(task_id),
377
598
  }
378
599
  end
379
600
 
data/lib/clacky/agent.rb CHANGED
@@ -252,7 +252,7 @@ module Clacky
252
252
  @name = new_name.to_s.strip
253
253
  end
254
254
 
255
- def run(user_input, files: [], display_text: nil)
255
+ def run(user_input, files: [], display_text: nil, created_at: nil)
256
256
  # Show the "thinking" indicator as early as possible so the user gets
257
257
  # immediate feedback after sending a message. Without this the UI stays
258
258
  # silent during synchronous setup work (system prompt assembly, file
@@ -262,7 +262,7 @@ module Clacky
262
262
  @ui&.show_progress
263
263
 
264
264
  # Start new task for Time Machine
265
- task_id = start_new_task
265
+ task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
266
266
 
267
267
  # Continuation of a previously-interrupted task (e.g. user sent a
268
268
  # supplementary message without stopping the running task) keeps the
@@ -361,7 +361,8 @@ module Clacky
361
361
  preview_path: f[:preview_path] || f["preview_path"] }
362
362
  end
363
363
 
364
- @history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f,
364
+ created_at ||= Time.now.to_f
365
+ @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
365
366
  display_text: display_text,
366
367
  display_files: display_files.empty? ? nil : display_files })
367
368
  @total_tasks += 1
@@ -1371,6 +1372,17 @@ module Clacky
1371
1372
  cloned_messages = deep_clone(@history.to_a)
1372
1373
  subagent.instance_variable_set(:@history, MessageHistory.new(cloned_messages))
1373
1374
 
1375
+ # The cloned history carries per-message task_id tags. Without the parent's
1376
+ # Time Machine task state the subagent's @active_task_id stays 0, so
1377
+ # active_task_chain collapses to {0} and active_messages filters out every
1378
+ # message tagged task_id > 0 — silently shrinking the context and busting
1379
+ # prompt caching. Carry the task state alongside @history so the subagent
1380
+ # sees the same chain (and cache prefix) as the parent.
1381
+ subagent.instance_variable_set(:@task_parents, deep_clone(@task_parents))
1382
+ subagent.instance_variable_set(:@current_task_id, @current_task_id)
1383
+ subagent.instance_variable_set(:@active_task_id, @active_task_id)
1384
+ subagent.instance_variable_set(:@task_meta, deep_clone(@task_meta))
1385
+
1374
1386
  # Append system prompt suffix as user message (for cache reuse)
1375
1387
  if system_prompt_suffix
1376
1388
  subagent_history = subagent.history
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Clacky
7
+ # Base class for user-defined HTTP API extensions loaded from
8
+ # ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL
9
+ # (get/post/put/patch/delete) to expose endpoints under
10
+ # /api/ext/<name>/<sub-path>
11
+ #
12
+ # The framework wires up access-key auth, timeouts, JSON error envelopes,
13
+ # path-parameter parsing, and a curated handler context — extension authors
14
+ # only fill in business logic.
15
+ #
16
+ # Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
17
+ #
18
+ # class MyDashboardExt < Clacky::ApiExtension
19
+ # get "/summary" do
20
+ # json(sessions: session_manager.list.size)
21
+ # end
22
+ # end
23
+ #
24
+ # Mounted automatically at: GET /api/ext/my-dashboard/summary
25
+ class ApiExtension
26
+ HTTP_METHODS = %i[get post put patch delete].freeze
27
+ MAX_TIMEOUT = 600
28
+ DEFAULT_TIMEOUT = 10
29
+
30
+ Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
31
+
32
+ class Halt < StandardError
33
+ attr_reader :status, :payload, :content_type
34
+
35
+ def initialize(status, payload, content_type)
36
+ super("api_ext halt #{status}")
37
+ @status = status
38
+ @payload = payload
39
+ @content_type = content_type
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Registry of all loaded ApiExtension subclasses, keyed by extension id
45
+ # (== directory name == mount prefix segment).
46
+ def registry
47
+ @registry ||= {}
48
+ end
49
+
50
+ def register(ext_id, klass)
51
+ registry[ext_id] = klass
52
+ end
53
+
54
+ def reset_registry!
55
+ @registry = {}
56
+ @pending_subclasses = []
57
+ end
58
+
59
+ # Captures every subclass at the moment its `class` body finishes being
60
+ # required — the loader pops the most recent one off this list to bind
61
+ # an ext_id/dir without relying on ObjectSpace scans.
62
+ def pending_subclasses
63
+ @pending_subclasses ||= []
64
+ end
65
+
66
+ def inherited(subclass)
67
+ super
68
+ Clacky::ApiExtension.pending_subclasses << subclass
69
+ end
70
+
71
+ # Per-subclass state — inherited classes carry their own routes/options.
72
+ def routes
73
+ @routes ||= []
74
+ end
75
+
76
+ def class_timeout
77
+ @class_timeout
78
+ end
79
+
80
+ def public_paths
81
+ @public_paths ||= []
82
+ end
83
+
84
+ def ext_id
85
+ @ext_id
86
+ end
87
+
88
+ def ext_id=(value)
89
+ @ext_id = value
90
+ end
91
+
92
+ def ext_dir
93
+ @ext_dir
94
+ end
95
+
96
+ def ext_dir=(value)
97
+ @ext_dir = value
98
+ end
99
+
100
+ def meta
101
+ @meta ||= {}
102
+ end
103
+
104
+ def meta=(value)
105
+ @meta = value || {}
106
+ end
107
+
108
+ # Set a default timeout (seconds) for every handler in this class.
109
+ # Per-route override available via `get "/x", timeout: 30 do ... end`.
110
+ def timeout(seconds)
111
+ raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
112
+ raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT
113
+
114
+ @class_timeout = seconds.to_f
115
+ end
116
+
117
+ # Mark a route as not requiring access-key auth. Caller must also
118
+ # declare `public: true` in meta.yml for the framework to honor this.
119
+ def public_endpoint(pattern)
120
+ public_paths << normalize_pattern(pattern)
121
+ end
122
+
123
+ HTTP_METHODS.each do |verb|
124
+ define_method(verb) do |pattern, **opts, &block|
125
+ raise ArgumentError, "missing handler block for #{verb.upcase} #{pattern}" unless block
126
+
127
+ per_route_timeout = opts[:timeout]
128
+ if per_route_timeout
129
+ raise ArgumentError, "timeout must be > 0" unless per_route_timeout.is_a?(Numeric) && per_route_timeout > 0
130
+ raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if per_route_timeout > MAX_TIMEOUT
131
+ end
132
+
133
+ normalized = normalize_pattern(pattern)
134
+ regex, param_names = compile_pattern(normalized)
135
+ routes << Route.new(
136
+ method: verb,
137
+ pattern: normalized,
138
+ regex: regex,
139
+ param_names: param_names,
140
+ block: block,
141
+ options: opts.dup
142
+ )
143
+ end
144
+ end
145
+
146
+ def normalize_pattern(pattern)
147
+ pattern = pattern.to_s
148
+ pattern = "/#{pattern}" unless pattern.start_with?("/")
149
+ pattern = pattern.chomp("/")
150
+ pattern.empty? ? "/" : pattern
151
+ end
152
+
153
+ def compile_pattern(pattern)
154
+ param_names = []
155
+ regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
156
+ param_names << Regexp.last_match(1).to_sym
157
+ "([^/]+)"
158
+ end
159
+ [Regexp.new("\\A#{regex_str}\\z"), param_names]
160
+ end
161
+ end
162
+
163
+ attr_reader :req, :res, :route, :params
164
+
165
+ def initialize(req:, res:, route:, params:, http_server:)
166
+ @req = req
167
+ @res = res
168
+ @route = route
169
+ @params = params
170
+ @http_server = http_server
171
+ end
172
+
173
+ def invoke
174
+ instance_exec(&route.block)
175
+ end
176
+
177
+ # ---- handler context (white-listed access to host process) ----
178
+
179
+ def json(*args, **kwargs)
180
+ if args.empty?
181
+ # Treat kwargs as the body: json(foo: 1, bar: 2)
182
+ # For non-200 status, pass an explicit hash: json({foo: 1}, status: 422)
183
+ raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
184
+ elsif args.size == 1
185
+ status = kwargs[:status] || 200
186
+ raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
187
+ else
188
+ raise ArgumentError, "json: expected (hash) or (key: value, ...)"
189
+ end
190
+ end
191
+
192
+ def text(str, status: 200)
193
+ raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
194
+ end
195
+
196
+ def error!(message, status: 400, **extra)
197
+ payload = { error: message.to_s }
198
+ payload.merge!(extra) unless extra.empty?
199
+ raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
200
+ end
201
+
202
+ def json_body
203
+ @json_body ||= begin
204
+ return {} if req.body.nil? || req.body.empty?
205
+ JSON.parse(req.body)
206
+ rescue JSON::ParserError
207
+ {}
208
+ end
209
+ end
210
+
211
+ def query
212
+ @query ||= req.query || {}
213
+ end
214
+
215
+ def data_path(*parts)
216
+ base = File.join(self.class.ext_dir, "data")
217
+ FileUtils.mkdir_p(base)
218
+ File.join(base, *parts.map(&:to_s))
219
+ end
220
+
221
+ def ext_dir
222
+ self.class.ext_dir
223
+ end
224
+
225
+ def ext_id
226
+ self.class.ext_id
227
+ end
228
+
229
+ def config
230
+ self.class.meta["config"] || {}
231
+ end
232
+
233
+ def session_manager
234
+ @http_server&.instance_variable_get(:@session_manager)
235
+ end
236
+
237
+ def agent_config
238
+ @http_server&.instance_variable_get(:@agent_config)
239
+ end
240
+
241
+ def server_start_time
242
+ @http_server&.instance_variable_get(:@start_time)
243
+ end
244
+
245
+ def logger
246
+ @logger ||= ScopedLogger.new(self.class.ext_id)
247
+ end
248
+
249
+ # Lightweight wrapper that prefixes log lines with the extension id.
250
+ class ScopedLogger
251
+ def initialize(ext_id)
252
+ @prefix = "[api_ext:#{ext_id}]"
253
+ end
254
+
255
+ %i[debug info warn error].each do |level|
256
+ define_method(level) do |msg|
257
+ Clacky::Logger.public_send(level, "#{@prefix} #{msg}")
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end