openclacky 1.5.6 → 1.5.8

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 +59 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/README.md +2 -2
  7. data/README_CN.md +1 -1
  8. data/README_JA.md +1 -1
  9. data/Rakefile +15 -0
  10. data/docs/design-emit-and-background-skills.md +253 -0
  11. data/docs/mcp-architecture.md +48 -9
  12. data/docs/mcp.example.json +9 -0
  13. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  14. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  15. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  16. data/lib/clacky/agent/cost_tracker.rb +17 -2
  17. data/lib/clacky/agent/hook_manager.rb +14 -3
  18. data/lib/clacky/agent/memory_updater.rb +17 -18
  19. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  20. data/lib/clacky/agent/session_serializer.rb +136 -22
  21. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  22. data/lib/clacky/agent/skill_evolution.rb +11 -7
  23. data/lib/clacky/agent/skill_manager.rb +20 -18
  24. data/lib/clacky/agent/skill_reflector.rb +3 -6
  25. data/lib/clacky/agent.rb +113 -23
  26. data/lib/clacky/agent_config.rb +40 -18
  27. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  28. data/lib/clacky/cli.rb +4 -1
  29. data/lib/clacky/client.rb +1 -1
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
  32. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  33. data/lib/clacky/extension/api_extension.rb +11 -3
  34. data/lib/clacky/extension/api_loader.rb +4 -11
  35. data/lib/clacky/extension/packager.rb +1 -1
  36. data/lib/clacky/extension/verifier.rb +1 -1
  37. data/lib/clacky/fanout.rb +100 -0
  38. data/lib/clacky/locales/en.rb +2 -0
  39. data/lib/clacky/locales/zh.rb +2 -0
  40. data/lib/clacky/mcp/cli_commands.rb +61 -0
  41. data/lib/clacky/mcp/client.rb +18 -2
  42. data/lib/clacky/mcp/connection_manager.rb +85 -0
  43. data/lib/clacky/mcp/http_transport.rb +25 -9
  44. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  45. data/lib/clacky/mcp/oauth/config.rb +51 -0
  46. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  47. data/lib/clacky/mcp/oauth/session.rb +88 -0
  48. data/lib/clacky/media/generator.rb +3 -3
  49. data/lib/clacky/message_format/anthropic.rb +27 -13
  50. data/lib/clacky/message_history.rb +33 -2
  51. data/lib/clacky/providers.rb +13 -6
  52. data/lib/clacky/server/http_server.rb +102 -98
  53. data/lib/clacky/server/session_registry.rb +11 -15
  54. data/lib/clacky/server/web_ui_controller.rb +1 -1
  55. data/lib/clacky/session_manager.rb +21 -12
  56. data/lib/clacky/shell_hook_loader.rb +14 -8
  57. data/lib/clacky/tools/invoke_skill.rb +3 -2
  58. data/lib/clacky/tools/terminal.rb +11 -4
  59. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  60. data/lib/clacky/ui2/components/input_area.rb +1 -0
  61. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  62. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  63. data/lib/clacky/ui2/line_editor.rb +1 -0
  64. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  65. data/lib/clacky/ui2/progress_handle.rb +27 -3
  66. data/lib/clacky/ui2/ui_controller.rb +207 -6
  67. data/lib/clacky/ui_interface.rb +13 -3
  68. data/lib/clacky/utils/arguments_parser.rb +73 -4
  69. data/lib/clacky/utils/model_pricing.rb +21 -0
  70. data/lib/clacky/version.rb +1 -1
  71. data/lib/clacky/web/app.css +144 -213
  72. data/lib/clacky/web/components/chat-navigator.js +1 -1
  73. data/lib/clacky/web/features/billing/store.js +3 -3
  74. data/lib/clacky/web/features/billing/view.js +5 -61
  75. data/lib/clacky/web/features/extensions/store.js +1 -1
  76. data/lib/clacky/web/features/extensions/view.js +28 -5
  77. data/lib/clacky/web/features/new-session/view.js +43 -2
  78. data/lib/clacky/web/i18n.js +38 -22
  79. data/lib/clacky/web/index.html +19 -24
  80. data/lib/clacky/web/projects.js +96 -279
  81. data/lib/clacky/web/sessions.js +169 -119
  82. data/lib/clacky/web/settings.js +7 -1
  83. data/lib/clacky/web/ws-dispatcher.js +61 -34
  84. data/lib/clacky.rb +2 -0
  85. metadata +12 -1
@@ -68,20 +68,23 @@ module Clacky
68
68
  def run_memory_update_subagent
69
69
  return unless should_update_memory?
70
70
 
71
- with_memory_update_phase do
72
- run_memory_update_subagent_inner
73
- end
71
+ with_memory_update_phase { run_memory_update_subagent_inner }
74
72
  end
75
73
 
76
74
  private def with_memory_update_phase
77
75
  return yield unless @ui.respond_to?(:with_phase)
78
76
 
79
- @ui.with_phase(kind: "memory_update", label: "Updating long-term memory") { yield }
77
+ pid = @ui.phase_start(kind: "memory_update", label: "Updating long-term memory")
78
+ summary = nil
79
+ begin
80
+ summary = yield
81
+ ensure
82
+ @ui.phase_end(pid, summary: summary)
83
+ end
84
+ summary
80
85
  end
81
86
 
82
87
  private def run_memory_update_subagent_inner
83
- handle = @ui&.start_progress(message: "Updating long-term memory…", style: :primary)
84
-
85
88
  # Fork subagent inheriting main agent's model, tools, and history.
86
89
  # Maximizes prompt-cache reuse: same model, same tool set, same
87
90
  # cloned history — only the +system_prompt_suffix+ (the memory
@@ -114,8 +117,6 @@ module Clacky
114
117
  }
115
118
  Clacky::Logger.error("memory_update_error", error: e)
116
119
  return
117
- ensure
118
- handle&.finish
119
120
  end
120
121
 
121
122
  return unless result
@@ -124,16 +125,14 @@ module Clacky
124
125
  # sessionbar shows the real total. The parent's task-complete cost
125
126
  # (result[:total_cost_usd] in Agent#run) stays unaffected — it
126
127
  # still reflects ONLY the user's task, not the memory update.
127
- subagent_cost = result[:total_cost_usd] || 0.0
128
- @total_cost += subagent_cost
129
- @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
130
-
131
- # Only surface a completion info line if the subagent actually
132
- # wrote something to memory. The common "No memory updates needed."
133
- # path stays silent to avoid visual noise.
134
- if subagent_wrote_memory?(subagent)
135
- @ui&.show_info("Memory updated: #{result[:iterations]} iterations, $#{subagent_cost.round(4)}")
136
- end
128
+ subagent_cost = absorb_subagent_cost(result)
129
+
130
+ # A summary is only worth showing if the subagent actually wrote
131
+ # something. The common "No memory updates needed." path returns nil
132
+ # so the phase leaves no trace at all.
133
+ return unless subagent_wrote_memory?(subagent)
134
+
135
+ "#{result[:iterations]} steps · $#{subagent_cost.round(4)}"
137
136
  end
138
137
 
139
138
  private def memory_update_enabled?
@@ -628,6 +628,7 @@ module Clacky
628
628
  lines << ""
629
629
  lines << format_message_content(content)
630
630
  lines << ""
631
+ append_ext_events_line(lines, msg)
631
632
  when "assistant"
632
633
  # If this message is itself a compressed summary, annotate the header
633
634
  # so the reader knows the original conversation is in the referenced chunk
@@ -660,6 +661,7 @@ module Clacky
660
661
  end
661
662
  lines << format_message_content(content) if content
662
663
  lines << ""
664
+ append_ext_events_line(lines, msg)
663
665
  when "tool"
664
666
  tool_name = msg[:name] || "tool"
665
667
  lines << "### Tool Result: #{tool_name}"
@@ -668,11 +670,32 @@ module Clacky
668
670
  lines << truncate_content(content.to_s, max_length: 500)
669
671
  lines << "```"
670
672
  lines << ""
673
+ append_ext_events_line(lines, msg)
671
674
  end
672
675
  end
673
676
  lines
674
677
  end
675
678
 
679
+ # Serialize persisted extension events into the chunk MD so they survive
680
+ # compression and can be replayed in place. Mirrors the "_Tool calls:"
681
+ # convention; parse_chunk_md_to_rounds reads it back.
682
+ def append_ext_events_line(lines, msg)
683
+ events = Array(msg[:ext_events])
684
+ return if events.empty?
685
+
686
+ parts = events.filter_map do |ev|
687
+ type = ev[:type] || ev["type"]
688
+ next nil if type.nil? || type.to_s.empty?
689
+
690
+ data = ev[:data] || ev["data"] || {}
691
+ "#{type} | #{data.to_json}"
692
+ end
693
+ return if parts.empty?
694
+
695
+ lines << "_Ext events: #{parts.join("; ")}_"
696
+ lines << ""
697
+ end
698
+
676
699
  # Format message content (handles string or array of content blocks)
677
700
  def format_message_content(content)
678
701
  return "" if content.nil?
@@ -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
@@ -226,7 +227,6 @@ module Clacky
226
227
  session_id: @session_id,
227
228
  name: @name,
228
229
  pinned: @pinned,
229
- hidden: @hidden,
230
230
  created_at: @created_at,
231
231
  updated_at: (updated_at || @persisted_updated_at || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
232
232
  working_dir: @working_dir,
@@ -393,6 +393,7 @@ module Clacky
393
393
  all_files = image_files + disk_files
394
394
  ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
395
395
  editable: round[:editable] != false)
396
+ replay_ext_events(msg, ui)
396
397
 
397
398
  round[:events].each do |ev|
398
399
  # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
@@ -522,6 +523,7 @@ module Clacky
522
523
 
523
524
  sections.each do |sec|
524
525
  text = sec[:lines].join.strip
526
+ text, sec_ext_events = extract_ext_events_from_text(text)
525
527
 
526
528
  # Nested chunk: expand it recursively, prepend before current rounds
527
529
  if sec[:nested_chunk]
@@ -534,7 +536,7 @@ module Clacky
534
536
  next
535
537
  end
536
538
 
537
- next if text.empty?
539
+ next if text.empty? && sec_ext_events.empty?
538
540
 
539
541
  if sec[:role] == "user"
540
542
  round_index += 1
@@ -545,6 +547,7 @@ module Clacky
545
547
  role: "user",
546
548
  content: text,
547
549
  created_at: synthetic_ts,
550
+ ext_events: sec_ext_events,
548
551
  _from_chunk: true
549
552
  },
550
553
  events: [],
@@ -569,19 +572,7 @@ module Clacky
569
572
  stripped = line.strip
570
573
  if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
571
574
  raw = m[1]
572
- # New format uses ";" as separator between tools (each entry: "name | {json}")
573
- # Old format uses "," with no JSON part.
574
- entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
575
- entries.each do |entry|
576
- entry = entry.strip
577
- if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
578
- tool_name = parts[1].strip
579
- args = JSON.parse(parts[2]) rescue {}
580
- pending_tool_entries << { name: tool_name, args: args }
581
- else
582
- pending_tool_entries << { name: entry, args: {} }
583
- end
584
- end
575
+ pending_tool_entries.concat(parse_tool_calls_line(raw))
585
576
  else
586
577
  remaining_lines << line
587
578
  end
@@ -599,8 +590,11 @@ module Clacky
599
590
  tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
600
591
  }
601
592
  end
593
+
594
+ attach_ext_events_to_last(current_round, sec_ext_events)
602
595
  else
603
596
  current_round[:events] << { role: "tool", content: text }
597
+ attach_ext_events_to_last(current_round, sec_ext_events)
604
598
  end
605
599
  end
606
600
  end
@@ -612,6 +606,108 @@ module Clacky
612
606
  end
613
607
 
614
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
+
615
711
  # Render a single non-user message into the UI.
616
712
  # Used by both the normal round-based replay and the compressed-session fallback.
617
713
  def _replay_single_message(msg, ui)
@@ -664,20 +760,38 @@ module Clacky
664
760
 
665
761
  when "user"
666
762
  # Anthropic-format tool results (role: user, content: array of tool_result blocks)
667
- return unless msg[:content].is_a?(Array)
668
-
669
- msg[:content].each do |blk|
670
- next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
763
+ if msg[:content].is_a?(Array)
764
+ msg[:content].each do |blk|
765
+ next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
671
766
 
672
- 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)
673
770
  end
674
- replay_subagent_transcript(msg, ui)
675
771
 
676
772
  when "tool"
677
773
  # OpenAI-format tool result
678
774
  ui.show_tool_result(msg[:content].to_s)
679
775
  replay_subagent_transcript(msg, ui)
680
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
681
795
  end
682
796
 
683
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?