openclacky 1.5.5 → 1.5.7

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +58 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/goal_manager.rb +305 -0
  15. data/lib/clacky/agent/goal_state.rb +88 -0
  16. data/lib/clacky/agent/hook_manager.rb +14 -3
  17. data/lib/clacky/agent/memory_updater.rb +17 -18
  18. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  19. data/lib/clacky/agent/session_serializer.rb +162 -25
  20. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  21. data/lib/clacky/agent/skill_evolution.rb +11 -7
  22. data/lib/clacky/agent/skill_manager.rb +20 -18
  23. data/lib/clacky/agent/skill_reflector.rb +3 -6
  24. data/lib/clacky/agent.rb +312 -22
  25. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  26. data/lib/clacky/cli.rb +19 -15
  27. data/lib/clacky/client.rb +1 -1
  28. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  29. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
  32. data/lib/clacky/default_searchers/tavily.rb +98 -0
  33. data/lib/clacky/extension/api_extension.rb +37 -3
  34. data/lib/clacky/extension/packager.rb +54 -5
  35. data/lib/clacky/fanout.rb +100 -0
  36. data/lib/clacky/idle_compression_timer.rb +1 -3
  37. data/lib/clacky/locales/en.rb +2 -0
  38. data/lib/clacky/locales/zh.rb +2 -0
  39. data/lib/clacky/mcp/cli_commands.rb +61 -0
  40. data/lib/clacky/mcp/client.rb +18 -2
  41. data/lib/clacky/mcp/connection_manager.rb +85 -0
  42. data/lib/clacky/mcp/http_transport.rb +25 -9
  43. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  44. data/lib/clacky/mcp/oauth/config.rb +51 -0
  45. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  46. data/lib/clacky/mcp/oauth/session.rb +88 -0
  47. data/lib/clacky/message_format/anthropic.rb +27 -13
  48. data/lib/clacky/message_history.rb +32 -1
  49. data/lib/clacky/providers.rb +15 -13
  50. data/lib/clacky/search_config.rb +57 -0
  51. data/lib/clacky/server/http_server.rb +136 -37
  52. data/lib/clacky/server/session_registry.rb +21 -18
  53. data/lib/clacky/server/web_ui_controller.rb +1 -1
  54. data/lib/clacky/session_manager.rb +13 -11
  55. data/lib/clacky/shell_hook_loader.rb +14 -8
  56. data/lib/clacky/tools/invoke_skill.rb +3 -2
  57. data/lib/clacky/tools/terminal.rb +11 -4
  58. data/lib/clacky/tools/web_search.rb +45 -6
  59. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  60. data/lib/clacky/ui2/components/input_area.rb +26 -2
  61. data/lib/clacky/ui2/progress_handle.rb +27 -3
  62. data/lib/clacky/ui2/ui_controller.rb +223 -6
  63. data/lib/clacky/ui_interface.rb +22 -3
  64. data/lib/clacky/utils/arguments_parser.rb +73 -4
  65. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  66. data/lib/clacky/utils/model_pricing.rb +21 -10
  67. data/lib/clacky/utils/searcher_manager.rb +87 -0
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +243 -33
  70. data/lib/clacky/web/app.js +12 -1
  71. data/lib/clacky/web/components/chat-navigator.js +260 -0
  72. data/lib/clacky/web/features/billing/view.js +2 -2
  73. data/lib/clacky/web/features/brand/view.js +5 -1
  74. data/lib/clacky/web/features/extensions/view.js +19 -19
  75. data/lib/clacky/web/features/new-session/view.js +19 -2
  76. data/lib/clacky/web/features/skills/store.js +4 -2
  77. data/lib/clacky/web/i18n.js +64 -14
  78. data/lib/clacky/web/index.html +40 -9
  79. data/lib/clacky/web/projects.js +62 -19
  80. data/lib/clacky/web/sessions.js +329 -115
  81. data/lib/clacky/web/settings.js +86 -0
  82. data/lib/clacky/web/skills.js +24 -4
  83. data/lib/clacky/web/ws-dispatcher.js +86 -35
  84. data/lib/clacky.rb +4 -0
  85. metadata +18 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "strscan"
4
+
3
5
  module Clacky
4
6
  class Agent
5
7
  # Session serialization for saving and restoring agent state
@@ -11,7 +13,6 @@ module Clacky
11
13
  @session_id = session_data[:session_id]
12
14
  @name = session_data[:name] || ""
13
15
  @pinned = session_data[:pinned] || false
14
- @hidden = session_data[:hidden] || false
15
16
  @history = MessageHistory.new(session_data[:messages] || [])
16
17
  @todos = session_data[:todos] || [] # Restore todos from session
17
18
  @iterations = session_data.dig(:stats, :total_iterations) || 0
@@ -33,6 +34,12 @@ module Clacky
33
34
  # Restore project association (nil = no project)
34
35
  @project_id = session_data[:project_id]
35
36
 
37
+ # Restore standing goal state (nil = no goal). The judge client/model
38
+ # are rebound lazily via #goal_manager when first accessed after
39
+ # restore — but the persisted state must be reattached here so a
40
+ # restored active goal keeps its progress and continues.
41
+ restore_goal_state(session_data[:goal])
42
+
36
43
  # Restore cache statistics if available
37
44
  @cache_stats = session_data.dig(:stats, :cache_stats) || {
38
45
  cache_creation_input_tokens: 0,
@@ -120,6 +127,22 @@ module Clacky
120
127
  refresh_system_prompt
121
128
  end
122
129
 
130
+ # Reattach persisted goal state after a restore. Builds a fresh
131
+ # GoalManager (bound to this agent's judge client/model) and injects the
132
+ # deserialized GoalState so a restored active goal keeps its progress.
133
+ private def restore_goal_state(goal_data)
134
+ return if goal_data.nil? || goal_data.empty?
135
+
136
+ state = GoalState.from_h(goal_data)
137
+ @goal_manager = GoalManager.new(
138
+ judge_client: @client,
139
+ judge_model: judge_model_name,
140
+ state: state
141
+ )
142
+ rescue => e
143
+ Clacky::Logger.warn("restore_goal_state failed: #{e.message}")
144
+ end
145
+
123
146
  # Fill missing entries in @task_meta from @history (for sessions saved
124
147
  # before task_meta existed, or for tasks whose meta was lost). The first
125
148
  # real user message of each task supplies the title; created_at becomes
@@ -168,7 +191,7 @@ module Clacky
168
191
  latest_path = chunks.last[:path]
169
192
  broken.each { |m| m[:chunk_path] = latest_path }
170
193
 
171
- session_manager.save(to_session_data(preserve_updated_at: true))
194
+ session_manager.save(to_session_data)
172
195
  rescue => e
173
196
  Clacky::Logger.warn("heal_missing_chunk_paths! failed: #{e.message}")
174
197
  end
@@ -183,7 +206,7 @@ module Clacky
183
206
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
184
207
  # @param error_message [String] Error message if status is :error
185
208
  # @return [Hash] Session data ready for serialization
186
- def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil, preserve_updated_at: false)
209
+ def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil)
187
210
  stats_data = {
188
211
  total_tasks: @total_tasks,
189
212
  total_iterations: @iterations,
@@ -204,9 +227,8 @@ module Clacky
204
227
  session_id: @session_id,
205
228
  name: @name,
206
229
  pinned: @pinned,
207
- hidden: @hidden,
208
230
  created_at: @created_at,
209
- updated_at: (updated_at || (preserve_updated_at && @persisted_updated_at) || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
231
+ updated_at: (updated_at || @persisted_updated_at || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
210
232
  working_dir: @working_dir,
211
233
  source: @source.to_s, # "manual" | "cron" | "channel" | "setup"
212
234
  agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom
@@ -241,6 +263,7 @@ module Clacky
241
263
  },
242
264
  channel_info: @channel_info,
243
265
  project_id: @project_id,
266
+ goal: @goal_manager&.to_h,
244
267
  stats: stats_data,
245
268
  messages: @history.to_a
246
269
  }
@@ -370,6 +393,7 @@ module Clacky
370
393
  all_files = image_files + disk_files
371
394
  ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
372
395
  editable: round[:editable] != false)
396
+ replay_ext_events(msg, ui)
373
397
 
374
398
  round[:events].each do |ev|
375
399
  # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
@@ -499,6 +523,7 @@ module Clacky
499
523
 
500
524
  sections.each do |sec|
501
525
  text = sec[:lines].join.strip
526
+ text, sec_ext_events = extract_ext_events_from_text(text)
502
527
 
503
528
  # Nested chunk: expand it recursively, prepend before current rounds
504
529
  if sec[:nested_chunk]
@@ -511,7 +536,7 @@ module Clacky
511
536
  next
512
537
  end
513
538
 
514
- next if text.empty?
539
+ next if text.empty? && sec_ext_events.empty?
515
540
 
516
541
  if sec[:role] == "user"
517
542
  round_index += 1
@@ -522,6 +547,7 @@ module Clacky
522
547
  role: "user",
523
548
  content: text,
524
549
  created_at: synthetic_ts,
550
+ ext_events: sec_ext_events,
525
551
  _from_chunk: true
526
552
  },
527
553
  events: [],
@@ -546,19 +572,7 @@ module Clacky
546
572
  stripped = line.strip
547
573
  if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
548
574
  raw = m[1]
549
- # New format uses ";" as separator between tools (each entry: "name | {json}")
550
- # Old format uses "," with no JSON part.
551
- entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
552
- entries.each do |entry|
553
- entry = entry.strip
554
- if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
555
- tool_name = parts[1].strip
556
- args = JSON.parse(parts[2]) rescue {}
557
- pending_tool_entries << { name: tool_name, args: args }
558
- else
559
- pending_tool_entries << { name: entry, args: {} }
560
- end
561
- end
575
+ pending_tool_entries.concat(parse_tool_calls_line(raw))
562
576
  else
563
577
  remaining_lines << line
564
578
  end
@@ -576,8 +590,11 @@ module Clacky
576
590
  tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
577
591
  }
578
592
  end
593
+
594
+ attach_ext_events_to_last(current_round, sec_ext_events)
579
595
  else
580
596
  current_round[:events] << { role: "tool", content: text }
597
+ attach_ext_events_to_last(current_round, sec_ext_events)
581
598
  end
582
599
  end
583
600
  end
@@ -589,6 +606,108 @@ module Clacky
589
606
  end
590
607
 
591
608
 
609
+ # Pull the "_Ext events: type | {json}; ..._" line out of a chunk section,
610
+ # returning the remaining text plus the parsed events. Counterpart to
611
+ # MessageCompressorHelper#append_ext_events_line.
612
+ def extract_ext_events_from_text(text)
613
+ return [text, []] unless text.include?("_Ext events:")
614
+
615
+ events = []
616
+ kept = text.each_line.reject do |line|
617
+ m = line.strip.match(/\A_Ext events?:\s*(.+?)_?\z/i)
618
+ next false unless m
619
+
620
+ parse_piped_entries(m[1]).each do |entry|
621
+ next if entry[:args].nil?
622
+
623
+ events << { type: entry[:name], data: entry[:args] }
624
+ end
625
+ true
626
+ end
627
+
628
+ [kept.join.strip, events]
629
+ end
630
+
631
+ # Split a "_Tool calls:_" payload into entries. The separator ";" also
632
+ # occurs inside args JSON (shell commands, JS snippets), so entry
633
+ # boundaries are found by brace balancing rather than String#split.
634
+ def parse_tool_calls_line(raw)
635
+ parse_piped_entries(raw).map { |e| { name: e[:name], args: e[:args] || {} } }
636
+ end
637
+
638
+ # Scan a "name | {json}; name | {json}" payload. Shared by the
639
+ # "_Tool calls:" and "_Ext events:" chunk MD lines, whose ";" separator
640
+ # is ambiguous with semicolons inside the JSON values. Entries with no
641
+ # JSON payload yield args: nil so callers can tell "absent" from "empty".
642
+ def parse_piped_entries(raw)
643
+ return [] if raw.nil?
644
+
645
+ scanner = StringScanner.new(raw)
646
+ entries = []
647
+
648
+ until scanner.eos?
649
+ scanner.skip(/[;,]?\s*/)
650
+ break if scanner.eos?
651
+
652
+ name = scanner.scan(/[^|;,]+?(?=\s*\|\s*\{)/)
653
+ unless name
654
+ plain = scanner.scan(/[^;,]+/)
655
+ break unless plain
656
+
657
+ plain = plain.strip
658
+ entries << { name: plain, args: nil } unless plain.empty?
659
+ next
660
+ end
661
+
662
+ scanner.skip(/\s*\|\s*/)
663
+ json = scan_balanced_json(scanner)
664
+ next unless json
665
+
666
+ parsed = JSON.parse(json) rescue nil
667
+ entries << { name: name.strip, args: parsed.is_a?(Hash) ? parsed : {} }
668
+ end
669
+
670
+ entries
671
+ end
672
+
673
+ # Consume one balanced {...} literal, ignoring braces inside JSON strings.
674
+ def scan_balanced_json(scanner)
675
+ start = scanner.pos
676
+ return nil unless scanner.getch == "{"
677
+
678
+ depth = 1
679
+ in_string = false
680
+ escaped = false
681
+
682
+ while (ch = scanner.getch)
683
+ if escaped
684
+ escaped = false
685
+ elsif ch == "\\"
686
+ escaped = true
687
+ elsif ch == '"'
688
+ in_string = !in_string
689
+ elsif !in_string && ch == "{"
690
+ depth += 1
691
+ elsif !in_string && ch == "}"
692
+ depth -= 1
693
+ return scanner.string[start...scanner.pos] if depth.zero?
694
+ end
695
+ end
696
+
697
+ nil
698
+ end
699
+
700
+ # Anchor chunk-restored events to the last message of the round so they
701
+ # replay after it, matching where they were emitted live.
702
+ def attach_ext_events_to_last(round, events)
703
+ return if events.nil? || events.empty?
704
+
705
+ target = round[:events].last || round[:user_msg]
706
+ return unless target
707
+
708
+ target[:ext_events] = Array(target[:ext_events]) + events
709
+ end
710
+
592
711
  # Render a single non-user message into the UI.
593
712
  # Used by both the normal round-based replay and the compressed-session fallback.
594
713
  def _replay_single_message(msg, ui)
@@ -641,20 +760,38 @@ module Clacky
641
760
 
642
761
  when "user"
643
762
  # Anthropic-format tool results (role: user, content: array of tool_result blocks)
644
- return unless msg[:content].is_a?(Array)
763
+ if msg[:content].is_a?(Array)
764
+ msg[:content].each do |blk|
765
+ next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
645
766
 
646
- msg[:content].each do |blk|
647
- next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
648
-
649
- ui.show_tool_result(blk[:content].to_s)
767
+ ui.show_tool_result(blk[:content].to_s)
768
+ end
769
+ replay_subagent_transcript(msg, ui)
650
770
  end
651
- replay_subagent_transcript(msg, ui)
652
771
 
653
772
  when "tool"
654
773
  # OpenAI-format tool result
655
774
  ui.show_tool_result(msg[:content].to_s)
656
775
  replay_subagent_transcript(msg, ui)
657
776
  end
777
+
778
+ replay_ext_events(msg, ui)
779
+ end
780
+
781
+ # Re-emit custom extension events recorded on a message so extension
782
+ # panels can rebuild their state after a page reload. Mirrors what
783
+ # Agent#emit_event pushed live. No-op when the message carries none.
784
+ def replay_ext_events(msg, ui)
785
+ Array(msg[:ext_events]).each do |ev|
786
+ next unless ev.is_a?(Hash)
787
+
788
+ type = ev[:type] || ev["type"]
789
+ next if type.nil? || type.to_s.empty?
790
+
791
+ data = ev[:data] || ev["data"] || {}
792
+ data = data.is_a?(Hash) ? data.transform_keys(&:to_sym) : {}
793
+ ui.emit(type.to_s, **data)
794
+ end
658
795
  end
659
796
 
660
797
  # Replay a subagent transcript stored on a tool result message. Emits a
@@ -20,11 +20,10 @@ module Clacky
20
20
  def maybe_create_skill_from_task
21
21
  return unless should_auto_create_skill?
22
22
 
23
- @ui&.show_info("Analyzing task for skill creation opportunity...")
24
-
25
23
  # Fork an isolated subagent to evaluate + create — does NOT touch main history
26
24
  subagent = fork_subagent
27
25
  subagent.run(build_skill_creation_prompt)
26
+ nil
28
27
  end
29
28
 
30
29
  # Determine if this task is a candidate for skill auto-creation
@@ -26,10 +26,11 @@ module Clacky
26
26
  def run_skill_evolution_hooks
27
27
  return unless skill_evolution_enabled?
28
28
  return if @is_subagent
29
- return unless skill_evolution_visible? || skill_evolution_has_work?
30
29
 
31
30
  with_skill_evolution_phase do
32
- if @skill_execution_context
31
+ if !skill_evolution_has_work?
32
+ Clacky::I18n.t("phase.skipped")
33
+ elsif @skill_execution_context
33
34
  maybe_reflect_on_skill
34
35
  else
35
36
  maybe_create_skill_from_task
@@ -37,10 +38,6 @@ module Clacky
37
38
  end
38
39
  end
39
40
 
40
- private def skill_evolution_visible?
41
- @config.respond_to?(:verbose) && @config.verbose
42
- end
43
-
44
41
  private def skill_evolution_has_work?
45
42
  if @skill_execution_context
46
43
  should_reflect_on_skill?
@@ -52,7 +49,14 @@ module Clacky
52
49
  private def with_skill_evolution_phase
53
50
  return yield unless @ui.respond_to?(:with_phase)
54
51
 
55
- @ui.with_phase(kind: "skill_evolution", label: "Reflecting on this task") { yield }
52
+ pid = @ui.phase_start(kind: "skill_evolution", label: Clacky::I18n.t("phase.skill_evolution"))
53
+ summary = nil
54
+ begin
55
+ summary = yield
56
+ ensure
57
+ @ui.phase_end(pid, summary: summary)
58
+ end
59
+ summary
56
60
  end
57
61
 
58
62
  # Check if skill evolution is enabled in config
@@ -576,8 +576,10 @@ module Clacky
576
576
  # Execute a skill in a forked subagent
577
577
  # @param skill [Skill] The skill to execute
578
578
  # @param arguments [String] Arguments for the skill
579
+ # @param tool_call_id [String, nil] The invoke_skill call this run belongs to;
580
+ # nil for the slash-command path, which has no tool result to attach to.
579
581
  # @return [String] Summary of subagent execution
580
- def execute_skill_with_subagent(skill, arguments)
582
+ def execute_skill_with_subagent(skill, arguments, tool_call_id: nil)
581
583
  touch_skill_for_lru(skill)
582
584
  # For encrypted brand skills with supporting scripts: decrypt to a tmpdir.
583
585
  # Subagent path has a clear boundary (subagent.run returns), so we shred inline
@@ -603,17 +605,22 @@ module Clacky
603
605
  system_prompt_suffix: skill_instructions
604
606
  )
605
607
 
606
- # Log which model the subagent is actually using (may differ from requested
607
- # when "lite" falls back to default due to no lite model configured)
608
- @ui&.show_info("Subagent start: #{skill.identifier}#{skill.name_zh.to_s.empty? ? "" : " (#{skill.name_zh})"} [#{subagent.current_model_info[:model]}]")
609
-
610
608
  # Run subagent with the actual task as the sole user turn.
611
609
  # If the user typed the skill command with no arguments (e.g. "/jade-appraisal"),
612
610
  # use a generic trigger phrase so the user message is never empty.
613
611
  task_input = arguments.to_s.strip.empty? ? "Please proceed." : arguments
614
612
 
615
613
  begin
616
- result = subagent.run(task_input)
614
+ # The model may differ from the requested one ("lite" falls back to
615
+ # default when no lite model is configured), so name it on the card.
616
+ phase_label = "#{skill.identifier} · #{subagent.current_model_info[:model]}"
617
+
618
+ result = within_phase(phase_label, kind: "subagent", concurrent: false) do
619
+ run_result = subagent.run(task_input)
620
+ cost = absorb_subagent_cost(run_result)
621
+ @ui&.show_info("Completed in #{run_result[:iterations]} iterations · $#{cost.round(4)}")
622
+ run_result
623
+ end
617
624
  rescue Clacky::AgentInterrupted
618
625
  # Subagent was interrupted by user (Ctrl+C).
619
626
  # Write an interrupted summary into history so the parent agent's history
@@ -642,9 +649,13 @@ module Clacky
642
649
  # Capture the subagent's own message trail (everything it appended after
643
650
  # the fork) so the parent session can persist and later replay the full
644
651
  # subagent process — not just the collapsed summary. Stored transiently
645
- # on the parent agent; observe() attaches it to the invoke_skill tool
646
- # result message. Kept out of the LLM payload via INTERNAL_FIELDS.
647
- @pending_subagent_transcript = extract_subagent_transcript(subagent, skill.identifier)
652
+ # on the parent agent, keyed by the invoke_skill call it belongs to;
653
+ # observe() attaches each trail to its own tool result message.
654
+ # Kept out of the LLM payload via INTERNAL_FIELDS.
655
+ if tool_call_id
656
+ @pending_subagent_transcripts[tool_call_id] =
657
+ extract_subagent_transcript(subagent, skill.identifier)
658
+ end
648
659
 
649
660
  # Mutate the subagent_instructions message in-place to become the result summary
650
661
  @history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
@@ -654,15 +665,6 @@ module Clacky
654
665
  m[:skill_name] = skill.identifier
655
666
  end
656
667
 
657
- # Merge subagent cost into parent agent's total so the sessionbar reflects
658
- # the real cumulative spend across all subagents
659
- subagent_cost = result[:total_cost_usd] || 0.0
660
- @total_cost += subagent_cost
661
- @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
662
-
663
- # Log completion
664
- @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{subagent_cost.round(4)} (total: $#{@total_cost.round(4)})")
665
-
666
668
  # Return summary as the skill execution result
667
669
  summary
668
670
  end
@@ -23,17 +23,14 @@ module Clacky
23
23
 
24
24
  skill_name = @skill_execution_context[:skill_name]
25
25
 
26
- @ui&.show_info("Reflecting on skill execution: #{skill_name}")
27
26
  subagent = fork_subagent
28
27
  result = subagent.run(build_skill_reflection_prompt(skill_name))
29
28
 
30
- if result
31
- subagent_cost = result[:total_cost_usd] || 0.0
32
- @total_cost += subagent_cost
33
- @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
34
- end
29
+ cost = absorb_subagent_cost(result) if result
35
30
 
36
31
  @skill_execution_context = nil
32
+
33
+ "#{skill_name} · $#{cost.round(4)}" if cost
37
34
  end
38
35
 
39
36
  private def should_reflect_on_skill?