openclacky 1.5.11 → 1.5.12

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/cost_tracker.rb +2 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +1 -1
  5. data/lib/clacky/agent/session_serializer.rb +37 -17
  6. data/lib/clacky/agent/time_machine.rb +1 -0
  7. data/lib/clacky/agent.rb +96 -19
  8. data/lib/clacky/billing/billing_record.rb +3 -0
  9. data/lib/clacky/billing/billing_store.rb +31 -5
  10. data/lib/clacky/billing/platform_billing.rb +148 -0
  11. data/lib/clacky/cancel_flag.rb +23 -0
  12. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  13. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  14. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +28 -0
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +34 -3
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +10 -0
  19. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  20. data/lib/clacky/fanout.rb +49 -2
  21. data/lib/clacky/json_ui_controller.rb +3 -2
  22. data/lib/clacky/message_history.rb +38 -0
  23. data/lib/clacky/plain_ui_controller.rb +15 -5
  24. data/lib/clacky/providers.rb +8 -3
  25. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  26. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  28. data/lib/clacky/server/http_server.rb +305 -25
  29. data/lib/clacky/server/web_ui_controller.rb +7 -5
  30. data/lib/clacky/session_manager.rb +74 -17
  31. data/lib/clacky/tools/ask_user.rb +3 -2
  32. data/lib/clacky/tools/terminal.rb +3 -0
  33. data/lib/clacky/ui2/ui_controller.rb +2 -2
  34. data/lib/clacky/ui_interface.rb +2 -2
  35. data/lib/clacky/utils/model_pricing.rb +22 -0
  36. data/lib/clacky/version.rb +1 -1
  37. data/lib/clacky/web/app.css +506 -37
  38. data/lib/clacky/web/components/code-editor.js +33 -1
  39. data/lib/clacky/web/components/composer.js +277 -0
  40. data/lib/clacky/web/components/mentions.js +599 -0
  41. data/lib/clacky/web/core/aside.js +3 -3
  42. data/lib/clacky/web/features/billing/store.js +2 -6
  43. data/lib/clacky/web/features/billing/view.js +12 -10
  44. data/lib/clacky/web/features/new-session/view.js +49 -12
  45. data/lib/clacky/web/features/workspace/view.js +712 -77
  46. data/lib/clacky/web/i18n.js +34 -6
  47. data/lib/clacky/web/index.html +33 -8
  48. data/lib/clacky/web/sessions.js +182 -71
  49. data/lib/clacky/web/settings.js +1 -0
  50. data/lib/clacky/web/skills.js +11 -17
  51. data/lib/clacky/web/ws-dispatcher.js +11 -33
  52. data/lib/clacky.rb +1 -0
  53. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cef597eb5d6c066192ba6c2c587e0dcd62fdf2dd9ed422161f71718acbb7f25
4
- data.tar.gz: 03bcd29f23eacd7b49a37db0494f7964757391ac52e8f3ef9787c49a6b2f8efb
3
+ metadata.gz: 858730306a6e6647d79a5ebb35160c52fba7ed26c62ec97043373e7f052332e2
4
+ data.tar.gz: b412bc405daae81f1b6d71dd4dd2dffc197a00ac97540ecfd4916f7daf58843d
5
5
  SHA512:
6
- metadata.gz: 7543c4f518bf5a2e20778472e60909b5eda8f727015b66aad37c69b2d256b53ba3cd5126d2b385a06456a45a6531fe4fdaad7c6d7f8b247aeb2a76bf1446a9f1
7
- data.tar.gz: d6ceb974a12d29096ae65bcbed3d6c6467880005101664919357572201846792faf4f89956ff5ca568519d74960674e214a39c61333e527741a9eb38083fcbf1
6
+ metadata.gz: 685d67556e68efd81a8bc446edd473a57939f0404b754e4432c7d29e073d2c47adc0c939fc5da3ea32144f3c6026aa17d73ff4766420399515089a3a8a4bc98d
7
+ data.tar.gz: dc85168d4b103185d20087635cfc5972c4e3e23b8dee95cb2ba28017d276dda425d8d92d23159dad463bd6cdcc29b8e02404d50dd4a79e5e6345193e8b4167c8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.12] - 2026-08-27
8
+
9
+ ### Added
10
+ - Add VS Code-style file tree with inline file viewer
11
+ - Add quick-access sidebar for directory picker with WSL support
12
+ - Add Web UI @mention references for files, directories and sessions
13
+ - Add Gemini 3.7 Flash model with STT/video support; switch OCR default to DeepSeek vision
14
+ - Add timestamps to assistant messages
15
+ - Merge platform usage API data with local billing records
16
+ - Refresh git panel on session completion
17
+ - Allow disabling local extensions
18
+ - Expose task mappings in core events (#496 - @YeEmrick)
19
+
20
+ ### Improved
21
+ - Move file-trash sweep off the session save path and speed up session creation
22
+ - Make recharge link a visible button on model cards
23
+
24
+ ### Fixed
25
+ - Fix ask_user free-text input: render without options, guard IME composition, route Enter, render as text in IM channels, keep questions concise (#497 - @afumu)
26
+ - Fix advisor recommendations: send structured options, keep card state per session, dismiss button no longer permanently suppresses
27
+ - Fix home favorite pointing at the Windows profile under WSL
28
+ - Fix session list pagination and pin truncation
29
+ - Preserve task ids in compressed history (#500 - @YeEmrick)
30
+ - Clear stale media model override when reverting to auto
31
+ - Normalize gemini-3.7-flash name for pricing
32
+ - Fix sidebar scrollbar visibility and session list styling
33
+ - Fix messages loading created_at
34
+ - Render parallel fan-out subagent phases as flat, isolated cards
35
+ - Preserve subagent transcripts when an interrupted fan-out is resumed
36
+ - Fix spinner in subagent raise process
37
+ - Truncate messages carrying the system_injected flag
38
+ - Stop reporting awaiting_feedback for question-mark endings
39
+ - Detach channel_ui and keep channel_info unique on /bind and /unbind
40
+
7
41
  ## [1.5.11] - 2026-08-23
8
42
 
9
43
  ### Added
@@ -136,11 +136,13 @@ module Clacky
136
136
  # This ensures all API calls are recorded for statistics purposes.
137
137
  billing_model = model || current_model
138
138
  effective_cost = cost || 0.0 # Use 0 if pricing is unknown
139
+ provider = @config.provider_id_for(@config.current_model)
139
140
 
140
141
  record = Billing::BillingRecord.new(
141
142
  session_id: @session_id,
142
143
  timestamp: Time.now,
143
144
  model: billing_model,
145
+ provider: provider,
144
146
  prompt_tokens: usage[:prompt_tokens] || 0,
145
147
  completion_tokens: usage[:completion_tokens] || 0,
146
148
  cache_read_tokens: usage[:cache_read_input_tokens] || 0,
@@ -624,7 +624,7 @@ module Clacky
624
624
 
625
625
  case role
626
626
  when "user"
627
- lines << "## User"
627
+ lines << (msg[:task_id] ? "## User [Task #{msg[:task_id]}]" : "## User")
628
628
  lines << ""
629
629
  lines << format_message_content(content)
630
630
  lines << ""
@@ -391,7 +391,11 @@ module Clacky
391
391
  preview_path: f[:preview_path] || f["preview_path"] }
392
392
  }
393
393
  all_files = image_files + disk_files
394
- ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
394
+ references = Array(msg[:display_references])
395
+ ref_options = references.empty? ? {} : { references: references }
396
+ task_options = msg[:task_id] ? { task_id: msg[:task_id] } : {}
397
+ ui.show_user_message(raw_text, **task_options, created_at: msg[:created_at], files: all_files,
398
+ **ref_options,
395
399
  editable: round[:editable] != false,
396
400
  skill_command: msg[:skill_command],
397
401
  skill_command_display: msg[:skill_command_display])
@@ -490,30 +494,34 @@ module Clacky
490
494
  current_role = nil
491
495
  current_lines = []
492
496
  current_nested_chunk = nil # chunk reference from a Compressed Summary heading
497
+ current_task_id = nil
493
498
 
494
499
  raw.each_line do |line|
495
500
  stripped = line.chomp
496
501
  if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
497
502
  # Nested chunk reference — record it, treat as assistant section
498
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
503
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
499
504
  current_role = "assistant"
500
505
  current_lines = []
501
506
  current_nested_chunk = File.join(chunk_dir, m[1])
502
- elsif stripped.match?(/\A## (User|Assistant)/)
503
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
504
- current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
507
+ current_task_id = nil
508
+ elsif (m = stripped.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
509
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
510
+ current_role = m[1].downcase
505
511
  current_lines = []
506
512
  current_nested_chunk = nil
513
+ current_task_id = m[2]&.to_i
507
514
  elsif stripped.match?(/\A### Tool Result:/)
508
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
515
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
509
516
  current_role = "tool"
510
517
  current_lines = []
511
518
  current_nested_chunk = nil
519
+ current_task_id = nil
512
520
  else
513
521
  current_lines << line
514
522
  end
515
523
  end
516
- sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
524
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
517
525
 
518
526
  # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
519
527
  sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
@@ -544,14 +552,16 @@ module Clacky
544
552
  round_index += 1
545
553
  # Synthetic timestamp: spread rounds backwards from archived_at
546
554
  synthetic_ts = base_time - (sections.size - round_index) * 1.0
555
+ user_msg = {
556
+ role: "user",
557
+ content: text,
558
+ created_at: synthetic_ts,
559
+ ext_events: sec_ext_events,
560
+ _from_chunk: true
561
+ }
562
+ user_msg[:task_id] = sec[:task_id] if sec[:task_id]
547
563
  current_round = {
548
- user_msg: {
549
- role: "user",
550
- content: text,
551
- created_at: synthetic_ts,
552
- ext_events: sec_ext_events,
553
- _from_chunk: true
554
- },
564
+ user_msg: user_msg,
555
565
  events: [],
556
566
  # editable: false — this message was archived into a chunk MD and no
557
567
  # longer exists in the active in-memory @history, so it cannot be
@@ -733,7 +743,7 @@ module Clacky
733
743
  else
734
744
  raw_text
735
745
  end
736
- ui.show_assistant_message(text, files: [])
746
+ ui.show_assistant_message(text, files: [], created_at: msg[:created_at])
737
747
  end
738
748
 
739
749
  # Tool calls embedded in assistant message
@@ -761,6 +771,11 @@ module Clacky
761
771
  # Emit token usage stored on this message (for history replay display)
762
772
  ui.show_token_usage(msg[:token_usage]) if msg[:token_usage]
763
773
 
774
+ # Interrupted fan-out anchors its captured subagent trails onto the
775
+ # last message, which is usually this assistant turn (its tool result
776
+ # never got written). Replay them here so they survive a reload.
777
+ replay_subagent_transcript(msg, ui)
778
+
764
779
  when "user"
765
780
  # Anthropic-format tool results (role: user, content: array of tool_result blocks)
766
781
  if msg[:content].is_a?(Array)
@@ -817,8 +832,12 @@ module Clacky
817
832
  events = transcript[:events] || transcript["events"] || []
818
833
  return if events.empty?
819
834
 
835
+ skill = transcript[:skill] || transcript["skill"]
836
+ supports_phase = ui.respond_to?(:phase_start) && ui.respond_to?(:phase_end)
837
+ pid = ui.phase_start(kind: "fanout_subagent", label: skill) if supports_phase
838
+
820
839
  ui.show_subagent_start(
821
- skill: transcript[:skill] || transcript["skill"],
840
+ skill: skill,
822
841
  iterations: transcript[:iterations] || transcript["iterations"],
823
842
  cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
824
843
  )
@@ -831,7 +850,7 @@ module Clacky
831
850
  case role
832
851
  when "assistant"
833
852
  text = extract_text_from_content(content).to_s.strip
834
- ui.show_assistant_message(text, files: []) unless text.empty?
853
+ ui.show_assistant_message(text, files: [], created_at: ev[:created_at] || ev["created_at"]) unless text.empty?
835
854
  Array(tool_calls).each do |tc|
836
855
  name = tc[:name] || tc["name"] || ""
837
856
  args_raw = tc[:arguments] || tc["arguments"] || {}
@@ -844,6 +863,7 @@ module Clacky
844
863
  end
845
864
 
846
865
  ui.show_subagent_end
866
+ ui.phase_end(pid) if supports_phase && pid
847
867
  end
848
868
 
849
869
  # Replace the system message in @messages with a freshly built system prompt.
@@ -589,6 +589,7 @@ module Clacky
589
589
 
590
590
  tasks << {
591
591
  task_id: task_id,
592
+ parent_task_id: @task_parents[task_id].to_i,
592
593
  summary: summary,
593
594
  started_at: meta[:started_at],
594
595
  ended_at: meta[:ended_at],
data/lib/clacky/agent.rb CHANGED
@@ -113,6 +113,7 @@ module Clacky
113
113
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
114
114
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
115
115
  @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)
116
+ @cancel_flag = CancelFlag.new # Cooperative cancel: set by fan_out_labeled when the parent is interrupted; subagents on worker threads observe it via check_stale!
116
117
 
117
118
  # Compression tracking
118
119
  @compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
@@ -473,7 +474,7 @@ module Clacky
473
474
  @name = new_name.to_s.strip
474
475
  end
475
476
 
476
- def run(user_input, files: [], display_text: nil, created_at: nil)
477
+ def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
477
478
  # Intercept /goal ... commands before any task/LLM work. Control-plane
478
479
  # commands (status/pause/resume/clear) return immediately without a turn;
479
480
  # `/goal <text>` sets the goal, then falls through to run the first turn.
@@ -497,6 +498,10 @@ module Clacky
497
498
  # idempotent update on the same progress UI element.
498
499
  @ui&.show_progress
499
500
 
501
+ # A reused agent may carry a cancel flag set by a previously-interrupted
502
+ # fan-out batch; a fresh task must start uncancelled.
503
+ @cancel_flag = CancelFlag.new
504
+
500
505
  # Start new task for Time Machine
501
506
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
502
507
 
@@ -579,10 +584,19 @@ module Clacky
579
584
  # "too large" note for downgraded images.
580
585
  downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
581
586
  ocr_text = f[:ocr_text] || f["ocr_text"]
587
+ reference = f[:reference] || f["reference"]
588
+
589
+ # Directory references: capture only the path so the LLM can explore
590
+ # on demand with the read/shell tools.
591
+ if File.directory?(path.to_s)
592
+ next { name: name || File.basename(path.to_s), type: "directory", path: path.to_s,
593
+ reference: reference }
594
+ end
595
+
582
596
  ref = Utils::FileProcessor.process_path(path, name: name)
583
597
  { name: ref.name, type: ref.type.to_s, path: ref.original_path,
584
598
  preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path,
585
- downgrade_reason: downgrade_reason, ocr_text: ocr_text }
599
+ downgrade_reason: downgrade_reason, ocr_text: ocr_text, reference: reference }
586
600
  end
587
601
 
588
602
  # Build display_files for replay: lightweight metadata so the UI can reconstruct
@@ -591,6 +605,10 @@ module Clacky
591
605
  # images (provider has no vision / too large / OCR'd) DO need path here so the
592
606
  # UI can re-render them from the on-disk copy across session switches.
593
607
  display_files = all_disk_files.filter_map do |f|
608
+ # @mention file/directory references are replayed from display_references
609
+ # (with mention badges), so skip them here to avoid double-rendering as
610
+ # plain attachment badges.
611
+ next if f[:reference] || f["reference"]
594
612
  name = f[:name] || f["name"]
595
613
  next unless name
596
614
  { name: name, type: f[:type] || f["type"] || "file",
@@ -614,7 +632,8 @@ module Clacky
614
632
  display_text: display_text,
615
633
  skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
616
634
  skill_command_display: skill_command_display,
617
- display_files: display_files.empty? ? nil : display_files })
635
+ display_files: display_files.empty? ? nil : display_files,
636
+ display_references: Array(references_display).empty? ? nil : references_display })
618
637
  @total_tasks += 1
619
638
 
620
639
  # Inject disk file references as a system_injected message so:
@@ -640,6 +659,12 @@ module Clacky
640
659
 
641
660
  next unless name
642
661
 
662
+ # Directory reference: emit only the path so the LLM can explore on
663
+ # demand with the read/shell tools.
664
+ if type == "directory"
665
+ next ["[Directory: #{name}]", "Path: #{path}"].join("\n")
666
+ end
667
+
643
668
  lines = ["[File: #{name}]", "Type: #{type || "file"}"]
644
669
  lines << "Size: #{format_size(size_bytes)}" if size_bytes
645
670
  lines << "Original: #{path}" if path
@@ -680,17 +705,32 @@ module Clacky
680
705
  end
681
706
  end
682
707
 
683
- # If the user typed a slash command targeting a skill with disable-model-invocation: true,
684
- # inject the skill content as a synthetic assistant message so the LLM can act on it.
685
- # Skills already in the system prompt (model_invocation_allowed?) are skipped.
686
- inject_skill_command_as_assistant_message(skill_command, task_id)
687
-
688
- @hooks.trigger(:on_start, user_input)
708
+ # Inject referenced past chats (the @mention "send as reference" behavior)
709
+ # as a system_injected message same mechanism as file references: the LLM
710
+ # sees the context, but replay_history skips it and no user bubble renders.
711
+ Array(reference_contexts).each do |ctx|
712
+ next if ctx.to_s.strip.empty?
713
+ @history.append({ role: "user", content: ctx, system_injected: true, task_id: task_id })
714
+ end
689
715
 
690
716
  result = nil
691
717
  begin
718
+ # If the user typed a slash command targeting a skill with disable-model-invocation: true,
719
+ # inject the skill content as a synthetic assistant message so the LLM can act on it.
720
+ # Skills already in the system prompt (model_invocation_allowed?) are skipped.
721
+ # Inside the begin block so a fork_subagent failure (e.g. skill-declared model
722
+ # not found) still reaches the ensure block that stops the progress spinner.
723
+ inject_skill_command_as_assistant_message(skill_command, task_id)
724
+
725
+ @hooks.trigger(:on_start, user_input)
726
+
692
727
  # Track if ask_user was called
693
728
  awaiting_user_feedback = false
729
+ # Heuristic sibling of the above: the reply merely ended with a question
730
+ # mark. Kept separate because it must never reach build_result — the
731
+ # session status it feeds shows a "waiting" badge to the user, and a
732
+ # rhetorical closing question is not a request for input.
733
+ turn_unfinished = false
694
734
  # Track if task was interrupted by user (denied tool execution)
695
735
  task_interrupted = false
696
736
 
@@ -795,7 +835,7 @@ module Clacky
795
835
  )
796
836
  end
797
837
  if response[:content] && !response[:content].empty?
798
- emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
838
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], created_at: response[:created_at])
799
839
  end
800
840
 
801
841
  # Show token usage after the assistant message so WebUI renders it below the bubble
@@ -814,14 +854,14 @@ module Clacky
814
854
  # If the assistant ended its turn with a question, treat this as
815
855
  # an in-flight conversation (agent is awaiting the user's reply)
816
856
  # and skip skill evolution — the task isn't truly complete yet.
817
- awaiting_user_feedback = true if ends_with_question
857
+ turn_unfinished = true if ends_with_question
818
858
 
819
859
  break
820
860
  end
821
861
 
822
862
  # Show assistant message if there's content before tool calls
823
863
  if response[:content] && !response[:content].empty?
824
- emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true)
864
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true, created_at: response[:created_at])
825
865
  end
826
866
 
827
867
  # Show token usage after assistant message (or immediately if no message).
@@ -873,7 +913,7 @@ module Clacky
873
913
  # Run skill evolution hooks after main loop completes
874
914
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
875
915
  # Only for main agent (not subagents) to avoid recursive evolution
876
- unless @is_subagent || task_interrupted || awaiting_user_feedback
916
+ unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
877
917
  run_skill_evolution_hooks
878
918
  end
879
919
 
@@ -884,7 +924,7 @@ module Clacky
884
924
  # and only then [OK] Task Complete is printed. No cleanup dance,
885
925
  # no cross-method progress handle holding.
886
926
  # Skip on interrupt / feedback / subagent (self-guarded inside too).
887
- unless @is_subagent || task_interrupted || awaiting_user_feedback
927
+ unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
888
928
  run_memory_update_subagent
889
929
  end
890
930
 
@@ -892,6 +932,7 @@ module Clacky
892
932
  # Parent agent (skill_manager) prints the completion summary; skip here.
893
933
  else
894
934
  @ui&.show_complete(
935
+ task_id: result[:task_id],
895
936
  iterations: result[:iterations],
896
937
  cost: result[:total_cost_usd],
897
938
  cost_source: result[:cost_source],
@@ -915,6 +956,10 @@ module Clacky
915
956
 
916
957
  result
917
958
  rescue Clacky::AgentInterrupted
959
+ # A cancelled fan-out captured its subagents' progress but never reached
960
+ # observe() to persist it — anchor those trails now so a page reload
961
+ # after the interrupt still shows what the subagents did.
962
+ flush_pending_subagent_transcripts_on_interrupt
918
963
  # Mark this run as interrupted so the next run() (e.g. user's
919
964
  # supplementary message during a running task) keeps the existing
920
965
  # task-start snapshot — the completion summary should reflect the
@@ -1085,7 +1130,8 @@ module Clacky
1085
1130
  "- Break down large tasks into multiple smaller tool calls\n" \
1086
1131
  "- Keep each tool call argument under 2000 characters\n" \
1087
1132
  "- Use multiple tool calls instead of one large call",
1088
- truncated: true
1133
+ truncated: true,
1134
+ system_injected: true
1089
1135
  })
1090
1136
 
1091
1137
  # Close the current spinner so the warning appears cleanly;
@@ -1098,7 +1144,11 @@ module Clacky
1098
1144
  end
1099
1145
 
1100
1146
  # Add assistant response to history
1101
- msg = { role: "assistant", task_id: @current_task_id }
1147
+ created_at = Time.now.to_f
1148
+ msg = { role: "assistant", task_id: @current_task_id, created_at: created_at }
1149
+ # Surface the storage timestamp to the caller so the live UI emit uses
1150
+ # the same created_at that replay will later read back from history.
1151
+ response[:created_at] = created_at
1102
1152
  # Always include content field (some APIs require it even with tool_calls)
1103
1153
  # Use empty string instead of null for better compatibility
1104
1154
  msg[:content] = response[:content] || ""
@@ -1145,6 +1195,7 @@ module Clacky
1145
1195
  # to history — guarantees a stale thread cannot corrupt history with
1146
1196
  # tool messages that no longer have a matching assistant tool_calls.
1147
1197
  private def check_stale!
1198
+ raise Clacky::AgentInterrupted, "Fan-out batch cancelled by a newer task" if @cancel_flag&.cancelled?
1148
1199
  return unless @task_thread
1149
1200
  return if Thread.current == @task_thread
1150
1201
  raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
@@ -1488,6 +1539,26 @@ module Clacky
1488
1539
  end
1489
1540
  end
1490
1541
 
1542
+ # Interrupt-path counterpart to attach_pending_subagent_transcripts: a
1543
+ # cancelled fan-out never reaches observe(), so its captured job trails
1544
+ # would otherwise die in @pending_subagent_transcripts (memory only) and
1545
+ # vanish on the next page reload. Anchor whatever was captured onto the
1546
+ # last history message so it persists to session.json and replays.
1547
+ private def flush_pending_subagent_transcripts_on_interrupt
1548
+ pending = @subagent_transcripts_mutex.synchronize do
1549
+ snapshot = @pending_subagent_transcripts.dup
1550
+ @pending_subagent_transcripts.clear
1551
+ snapshot
1552
+ end
1553
+ return if pending.empty?
1554
+
1555
+ by_id = {}
1556
+ pending.each { |id, trails| by_id[id] = Array(trails).sort_by { |t| t[:index] || 0 } }
1557
+ @history.settle_interrupted_tool_calls(by_id)
1558
+ rescue StandardError => e
1559
+ Clacky::Logger.warn("agent.flush_subagent_transcripts_failed", error: e.message)
1560
+ end
1561
+
1491
1562
  # Cap oversized tool result content to keep a single tool message from
1492
1563
  # blowing up the prompt budget (issue #218: a 7350-path glob produced a
1493
1564
  # ~890k-char result that pushed history past the model context window
@@ -1611,6 +1682,7 @@ module Clacky
1611
1682
  {
1612
1683
  status: status,
1613
1684
  session_id: @session_id,
1685
+ task_id: @current_task_id,
1614
1686
  model: current_model,
1615
1687
  provider: current_provider,
1616
1688
  iterations: task_iterations,
@@ -1773,7 +1845,8 @@ module Clacky
1773
1845
  end
1774
1846
  end
1775
1847
 
1776
- Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(wrapped)
1848
+ Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
1849
+ .run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
1777
1850
  end
1778
1851
 
1779
1852
  private def within_phase(label, kind:, concurrent:, &block)
@@ -1899,6 +1972,10 @@ module Clacky
1899
1972
  )
1900
1973
  subagent.instance_variable_set(:@is_subagent, true)
1901
1974
 
1975
+ # Share the parent's cancel flag so a fan-out interrupt reaches this
1976
+ # subagent on its worker thread — its own check_stale! polls the same flag.
1977
+ subagent.instance_variable_set(:@cancel_flag, @cancel_flag)
1978
+
1902
1979
  # Inherit previous_total_tokens so the first iteration delta is calculated correctly
1903
1980
  subagent.instance_variable_set(:@previous_total_tokens, @previous_total_tokens)
1904
1981
 
@@ -2400,7 +2477,7 @@ module Clacky
2400
2477
  # and cannot load file:// directly) and must stay scoped to the Web UI
2401
2478
  # controller. IM channel subscribers need the original file:// markdown so
2402
2479
  # parse_file_links can extract paths and deliver images as native attachments.
2403
- private def emit_assistant_message(content, reasoning_content: nil, interim: false)
2480
+ private def emit_assistant_message(content, reasoning_content: nil, interim: false, created_at: nil)
2404
2481
  # Prepend reasoning/thinking content (from thinking-mode providers like
2405
2482
  # DeepSeek V4, Kimi K2) wrapped in <think> tags so the Web UI renders it
2406
2483
  # as a collapsible thinking block (see sessions.js _renderMarkdown).
@@ -2413,7 +2490,7 @@ module Clacky
2413
2490
  return if full_content.nil? || full_content.to_s.strip.empty?
2414
2491
 
2415
2492
  parsed = parse_file_links(content)
2416
- @ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim)
2493
+ @ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim, created_at: created_at)
2417
2494
  end
2418
2495
 
2419
2496
  # Record BEFORE-change snapshots for any file a tool is about to mutate,
@@ -9,6 +9,7 @@ module Clacky
9
9
  :session_id, # Associated session ID
10
10
  :timestamp, # Time of the API call
11
11
  :model, # Model used (e.g., "claude-sonnet-4.5")
12
+ :provider, # Provider id (e.g., "openclacky", "anthropic")
12
13
  :prompt_tokens, # Input tokens
13
14
  :completion_tokens, # Output tokens
14
15
  :cache_read_tokens, # Tokens read from cache
@@ -24,6 +25,7 @@ module Clacky
24
25
  session_id: session_id,
25
26
  timestamp: timestamp.is_a?(Time) ? timestamp.iso8601 : timestamp,
26
27
  model: model,
28
+ provider: provider,
27
29
  prompt_tokens: prompt_tokens || 0,
28
30
  completion_tokens: completion_tokens || 0,
29
31
  cache_read_tokens: cache_read_tokens || 0,
@@ -40,6 +42,7 @@ module Clacky
40
42
  session_id: hash[:session_id] || hash["session_id"],
41
43
  timestamp: parse_timestamp(hash[:timestamp] || hash["timestamp"]),
42
44
  model: hash[:model] || hash["model"],
45
+ provider: hash[:provider] || hash["provider"],
43
46
  prompt_tokens: hash[:prompt_tokens] || hash["prompt_tokens"] || 0,
44
47
  completion_tokens: hash[:completion_tokens] || hash["completion_tokens"] || 0,
45
48
  cache_read_tokens: hash[:cache_read_tokens] || hash["cache_read_tokens"] || 0,
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "securerandom"
6
6
  require_relative "billing_record"
7
7
  require_relative "../session_manager"
8
+ require_relative "../providers"
8
9
  module Clacky
9
10
  module Billing
10
11
  # Persistent storage for billing records using JSONL files
@@ -12,6 +13,27 @@ module Clacky
12
13
  class BillingStore
13
14
  BILLING_DIR = File.join(Dir.home, ".clacky", "billing")
14
15
 
16
+ # Preset keys that hold the openclacky model lineup (chat + media sidecars).
17
+ OPENCLACKY_MODEL_KINDS = %w[models image_models video_models audio_models stt_models video_understanding_models].freeze
18
+
19
+ # All openclacky model aliases, collected from the provider preset so the
20
+ # list never drifts from providers.rb as models are added or removed.
21
+ def openclacky_models
22
+ @openclacky_models ||= begin
23
+ preset = Clacky::Providers.get("openclacky")
24
+ return [] unless preset
25
+ OPENCLACKY_MODEL_KINDS.flat_map { |kind| preset[kind] || [] }.uniq
26
+ end
27
+ end
28
+
29
+ # True when a record belongs to the openclacky provider. New records
30
+ # carry an explicit provider id; legacy records (written before the
31
+ # field existed) are detected by matching the full openclacky model list.
32
+ def openclacky_record?(record)
33
+ return true if record.provider == "openclacky"
34
+ openclacky_models.include?(record.model.to_s)
35
+ end
36
+
15
37
  def initialize(billing_dir: nil)
16
38
  @billing_dir = billing_dir || ENV["CLACKY_BILLING_DIR"] || BILLING_DIR
17
39
  ensure_billing_dir
@@ -38,9 +60,10 @@ module Clacky
38
60
  # @param to [Time, nil] End time (inclusive)
39
61
  # @param model [String, nil] Filter by model name
40
62
  # @param session_id [String, nil] Filter by session ID
63
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
41
64
  # @param limit [Integer, nil] Maximum number of records to return
42
65
  # @return [Array<BillingRecord>] Matching records, newest first
43
- def query(from: nil, to: nil, model: nil, session_id: nil, limit: nil)
66
+ def query(from: nil, to: nil, model: nil, session_id: nil, exclude_openclacky: false, limit: nil)
44
67
  records = []
45
68
 
46
69
  billing_files.each do |file|
@@ -56,6 +79,7 @@ module Clacky
56
79
  next if to && record.timestamp > to
57
80
  next if model && record.model != model
58
81
  next if session_id && record.session_id != session_id
82
+ next if exclude_openclacky && openclacky_record?(record)
59
83
 
60
84
  records << record
61
85
  rescue JSON::ParserError
@@ -75,10 +99,11 @@ module Clacky
75
99
  # Get summary statistics for a time period
76
100
  # @param period [Symbol] :day, :week, :month, :year, or :all
77
101
  # @param model [String, nil] Filter by model name
102
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
78
103
  # @return [Hash] Summary with total_cost, total_tokens, by_model, etc.
79
- def summary(period: :month, model: nil)
104
+ def summary(period: :month, model: nil, exclude_openclacky: false)
80
105
  from_time = period_start(period)
81
- records = query(from: from_time, model: model)
106
+ records = query(from: from_time, model: model, exclude_openclacky: exclude_openclacky)
82
107
 
83
108
  total_cost = records.sum { |r| r.cost_usd || 0 }
84
109
  total_prompt = records.sum { |r| r.prompt_tokens || 0 }
@@ -222,10 +247,11 @@ module Clacky
222
247
 
223
248
  # Get daily cost breakdown for the last N days # @param days [Integer] Number of days to include
224
249
  # @param model [String, nil] Filter by model name
250
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
225
251
  # @return [Array<Hash>] Daily summaries with date and cost
226
- def daily_breakdown(days: 30, model: nil)
252
+ def daily_breakdown(days: 30, model: nil, exclude_openclacky: false)
227
253
  from_time = Time.now - (days * 24 * 60 * 60)
228
- records = query(from: from_time, model: model)
254
+ records = query(from: from_time, model: model, exclude_openclacky: exclude_openclacky)
229
255
 
230
256
  by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }
231
257