openclacky 1.5.10 → 1.5.11

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +4 -4
  4. data/README_CN.md +4 -4
  5. data/README_JA.md +4 -4
  6. data/lib/clacky/agent/llm_caller.rb +7 -3
  7. data/lib/clacky/agent/session_serializer.rb +24 -14
  8. data/lib/clacky/agent/skill_manager.rb +1 -2
  9. data/lib/clacky/agent/tool_executor.rb +1 -1
  10. data/lib/clacky/agent/tool_registry.rb +6 -4
  11. data/lib/clacky/agent.rb +105 -32
  12. data/lib/clacky/brand_config.rb +15 -17
  13. data/lib/clacky/cli.rb +2 -2
  14. data/lib/clacky/client.rb +5 -2
  15. data/lib/clacky/default_extensions/advisor/advisors/general.md +48 -0
  16. data/lib/clacky/default_extensions/advisor/api/handler.rb +27 -0
  17. data/lib/clacky/default_extensions/advisor/ext.yml +35 -0
  18. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +291 -0
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor_complete.rb +11 -0
  20. data/lib/clacky/default_extensions/advisor/hooks/advisor_tool_use.rb +12 -0
  21. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +462 -0
  22. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +6 -19
  23. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -0
  24. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -56
  25. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +14 -7
  26. data/lib/clacky/default_extensions/meeting/api/handler.rb +1 -1
  27. data/lib/clacky/default_skills/channel-manager/SKILL.md +4 -4
  28. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  29. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  30. data/lib/clacky/extension/hook_loader.rb +2 -0
  31. data/lib/clacky/extension/packager.rb +23 -10
  32. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +10 -22
  33. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +3 -3
  34. data/lib/clacky/fanout.rb +1 -1
  35. data/lib/clacky/idle_compression_timer.rb +3 -5
  36. data/lib/clacky/json_ui_controller.rb +1 -1
  37. data/lib/clacky/mcp/http_transport.rb +1 -1
  38. data/lib/clacky/mcp/registry.rb +2 -3
  39. data/lib/clacky/mcp/stdio_transport.rb +21 -17
  40. data/lib/clacky/plain_ui_controller.rb +19 -16
  41. data/lib/clacky/platform_http_client.rb +14 -14
  42. data/lib/clacky/providers.rb +15 -7
  43. data/lib/clacky/rich_ui/rich_ui_controller.rb +5 -5
  44. data/lib/clacky/server/browser_manager.rb +4 -7
  45. data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +2 -1
  46. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +7 -5
  47. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +4 -3
  48. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +3 -2
  49. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +5 -3
  50. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +6 -5
  51. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +13 -11
  52. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +7 -2
  53. data/lib/clacky/server/channel/channel_config.rb +25 -9
  54. data/lib/clacky/server/channel/channel_manager.rb +16 -9
  55. data/lib/clacky/server/channel/channel_ui_controller.rb +25 -6
  56. data/lib/clacky/server/http_server.rb +119 -47
  57. data/lib/clacky/server/scheduler.rb +4 -5
  58. data/lib/clacky/server/server_master.rb +15 -3
  59. data/lib/clacky/server/session_registry.rb +7 -2
  60. data/lib/clacky/server/web_ui_controller.rb +13 -14
  61. data/lib/clacky/session_manager.rb +11 -4
  62. data/lib/clacky/shell_hook_loader.rb +3 -3
  63. data/lib/clacky/shutdown.rb +71 -0
  64. data/lib/clacky/telemetry.rb +1 -1
  65. data/lib/clacky/thread_registry.rb +102 -0
  66. data/lib/clacky/tools/ask_user.rb +212 -0
  67. data/lib/clacky/tools/base.rb +5 -0
  68. data/lib/clacky/tools/terminal.rb +1 -1
  69. data/lib/clacky/tools/trash_manager.rb +16 -0
  70. data/lib/clacky/tools/web_search.rb +1 -1
  71. data/lib/clacky/ui2/components/input_area.rb +2 -2
  72. data/lib/clacky/ui2/progress_handle.rb +1 -2
  73. data/lib/clacky/ui2/progress_indicator.rb +1 -1
  74. data/lib/clacky/ui2/ui_controller.rb +22 -18
  75. data/lib/clacky/ui_interface.rb +3 -3
  76. data/lib/clacky/utils/arguments_parser.rb +31 -4
  77. data/lib/clacky/utils/model_pricing.rb +37 -0
  78. data/lib/clacky/utils/parser_manager.rb +2 -2
  79. data/lib/clacky/version.rb +1 -1
  80. data/lib/clacky/web/app.css +202 -36
  81. data/lib/clacky/web/components/notify.js +9 -7
  82. data/lib/clacky/web/core/ext.js +4 -6
  83. data/lib/clacky/web/features/channels/store.js +16 -1
  84. data/lib/clacky/web/features/channels/view.js +45 -2
  85. data/lib/clacky/web/i18n.js +46 -4
  86. data/lib/clacky/web/index.html +5 -7
  87. data/lib/clacky/web/sessions.js +384 -95
  88. data/lib/clacky/web/settings.js +31 -15
  89. data/lib/clacky/web/ws-dispatcher.js +41 -5
  90. data/lib/clacky.rb +3 -1
  91. metadata +11 -2
  92. data/lib/clacky/tools/request_user_feedback.rb +0 -84
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '038138bc72f3f740ed3ccee6da3f6464f80fd0cf1d0466788879a4472ac127b1'
4
- data.tar.gz: dfb75e0b2e832e923c34a1f5e61b4a005bd29435f3724763128b0fadc0b6ec1b
3
+ metadata.gz: 1cef597eb5d6c066192ba6c2c587e0dcd62fdf2dd9ed422161f71718acbb7f25
4
+ data.tar.gz: 03bcd29f23eacd7b49a37db0494f7964757391ac52e8f3ef9787c49a6b2f8efb
5
5
  SHA512:
6
- metadata.gz: c72531566a2b744a279e54907f750ff67e518963c9d42216dde3cb05048e4c86688dbb2ebfe908b31b7f7df8d2332df0ab0b11e0977ab5c486ca5c83b565eafc
7
- data.tar.gz: 584975067a26ebfc123e9f9a7f168ef45aab93f783e91da576510f416554c0ba4598e88064db7f71c0f6a30dfda949a4f36cc35d69aa35973489be800422a6b2
6
+ metadata.gz: 7543c4f518bf5a2e20778472e60909b5eda8f727015b66aad37c69b2d256b53ba3cd5126d2b385a06456a45a6531fe4fdaad7c6d7f8b247aeb2a76bf1446a9f1
7
+ data.tar.gz: d6ceb974a12d29096ae65bcbed3d6c6467880005101664919357572201846792faf4f89956ff5ca568519d74960674e214a39c61333e527741a9eb38083fcbf1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.11] - 2026-08-23
8
+
9
+ ### Added
10
+ - Creator Studio: separate Marketplace and Brand sections for published extensions
11
+ - Advisor extension for next-job suggestions
12
+ - ask_user tool with multi-question support (replaces feedback_user)
13
+ - Sidebar states for awaiting-feedback and just-finished sessions
14
+ - Auto-purge file-trash entries older than 8 days
15
+ - DeepSeek V4 flash vision exp model preset
16
+ - IM channel toggle to mute tool-call narration and previews
17
+
18
+ ### Improved
19
+ - Unified internal thread management with graceful shutdown
20
+
21
+ ### Fixed
22
+ - Skill decrypt retry now picks up a renewed license key
23
+ - DeepSeek weekends billed entirely at off-peak rates
24
+ - Skill and extension zip downloads fall back to a secondary CDN host
25
+ - Session status dot aligned with name and channel badge
26
+ - Project session tag top margin
27
+ - Unified modal close button and ext-studio modal semantics
28
+ - Unified checkbox accent and distinct verified badge
29
+ - Edit-model modal overflow on small screens
30
+ - Subagent UI fixes
31
+ - Star History charts migrated to star-history.dera.page (#491 - @CrustyMozarella)
32
+
7
33
  ## [1.5.10] - 2026-08-19
8
34
 
9
35
  ### Added
data/README.md CHANGED
@@ -209,11 +209,11 @@ $ openclacky
209
209
 
210
210
  ## Star History
211
211
 
212
- <a href="https://www.star-history.com/?repos=clacky-ai%2Fopenclacky&type=date&legend=top-left">
212
+ <a href="https://star-history.dera.page/#clacky-ai/openclacky&type=date&legend=top-left">
213
213
  <picture>
214
- <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
215
- <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
216
- <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
214
+ <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
215
+ <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
216
+ <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
217
217
  </picture>
218
218
  </a>
219
219
 
data/README_CN.md CHANGED
@@ -204,11 +204,11 @@ $ openclacky
204
204
 
205
205
  ## Star 历史
206
206
 
207
- <a href="https://www.star-history.com/?repos=clacky-ai%2Fopenclacky&type=date&legend=top-left">
207
+ <a href="https://star-history.dera.page/#clacky-ai/openclacky&type=date&legend=top-left">
208
208
  <picture>
209
- <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
210
- <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
211
- <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
209
+ <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
210
+ <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
211
+ <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
212
212
  </picture>
213
213
  </a>
214
214
 
data/README_JA.md CHANGED
@@ -209,11 +209,11 @@ $ openclacky
209
209
 
210
210
  ## Star History
211
211
 
212
- <a href="https://www.star-history.com/?repos=clacky-ai%2Fopenclacky&type=date&legend=top-left">
212
+ <a href="https://star-history.dera.page/#clacky-ai/openclacky&type=date&legend=top-left">
213
213
  <picture>
214
- <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
215
- <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
216
- <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
214
+ <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
215
+ <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
216
+ <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=clacky-ai/openclacky&type=date&legend=top-left" />
217
217
  </picture>
218
218
  </a>
219
219
 
@@ -95,6 +95,7 @@ module Clacky
95
95
 
96
96
  begin
97
97
  begin
98
+ Clacky::Shutdown.checkpoint!
98
99
  # Use active_messages (Time Machine) when undone, otherwise send full history.
99
100
  # to_api strips internal fields and handles orphaned tool_calls.
100
101
  messages_to_send = if respond_to?(:active_messages)
@@ -167,6 +168,7 @@ module Clacky
167
168
  end
168
169
 
169
170
  rescue Faraday::TimeoutError => e
171
+ Clacky::Shutdown.checkpoint!
170
172
  # Faraday::TimeoutError on our non-streaming POST almost always means
171
173
  # the *response* took longer than the 300s read-timeout to come back —
172
174
  # i.e. the model is trying to produce a huge output in one shot
@@ -198,7 +200,7 @@ module Clacky
198
200
  metadata: { attempt: retries, total: max_retries }
199
201
  )
200
202
  retrying_progress_opened = true
201
- sleep retry_delay
203
+ Clacky::Shutdown.sleep(retry_delay)
202
204
  retry
203
205
  else
204
206
  # All retries exhausted for a timeout. Try the secondary gateway URL if available.
@@ -219,6 +221,7 @@ module Clacky
219
221
  end
220
222
 
221
223
  rescue Faraday::ConnectionFailed, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE => e
224
+ Clacky::Shutdown.checkpoint!
222
225
  retries += 1
223
226
 
224
227
  # Errno::EPIPE means the underlying TCP socket is dead (server closed the
@@ -245,7 +248,7 @@ module Clacky
245
248
  metadata: { attempt: retries, total: max_retries }
246
249
  )
247
250
  retrying_progress_opened = true
248
- sleep retry_delay
251
+ Clacky::Shutdown.sleep(retry_delay)
249
252
  retry
250
253
  else
251
254
  # All retries on the primary endpoint exhausted for a network-level
@@ -269,6 +272,7 @@ module Clacky
269
272
  end
270
273
 
271
274
  rescue RetryableError => e
275
+ Clacky::Shutdown.checkpoint!
272
276
  retries += 1
273
277
 
274
278
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -298,7 +302,7 @@ module Clacky
298
302
  metadata: { attempt: retries, total: current_max }
299
303
  )
300
304
  retrying_progress_opened = true
301
- sleep retry_delay
305
+ Clacky::Shutdown.sleep(retry_delay)
302
306
  retry
303
307
  else
304
308
  # All retries on the current endpoint exhausted.
@@ -742,16 +742,17 @@ module Clacky
742
742
  args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {}
743
743
  args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
744
744
 
745
- # Special handling: request_user_feedback question is shown as an
746
- # assistant message (matching real-time behavior), not as a tool call.
747
- # Reconstruct the full formatted message including options (mirrors RequestUserFeedback#execute).
748
- if name == "request_user_feedback"
749
- question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : ""
750
- context = args.is_a?(Hash) ? (args[:context] || args["context"]).to_s : ""
751
- options = args.is_a?(Hash) ? (args[:options] || args["options"]) : nil
752
- options = Array(options) if options && !options.is_a?(Array)
753
-
754
- ui.show_feedback_request(question, context, options || []) unless question.empty?
745
+ # Special handling: the ask_user question is shown as an assistant
746
+ # message (matching real-time behavior), not as a tool call.
747
+ if Clacky::Tools::AskUser.feedback_tool?(name)
748
+ questions = Clacky::Tools::AskUser.normalize_questions(args)
749
+ context = Clacky::Tools::AskUser.fetch_key(args, :context).to_s
750
+ first = questions.first
751
+
752
+ unless first.nil?
753
+ ui.show_feedback_request(first[:question], context, first[:options],
754
+ questions: questions)
755
+ end
755
756
  else
756
757
  ui.show_tool_call(name, args)
757
758
  end
@@ -796,14 +797,23 @@ module Clacky
796
797
  end
797
798
  end
798
799
 
799
- # Replay a subagent transcript stored on a tool result message. Emits a
800
+ # Replay subagent transcripts stored on a tool result message. Emits a
800
801
  # bracketed sequence of UI events the frontend can render as a collapsible
801
802
  # sub-process block: subagent_start → (assistant_message / tool_call /
802
- # tool_result)* → subagent_end. No-op when the message carries no transcript.
803
+ # tool_result)* → subagent_end. A fan-out batch renders one block per job,
804
+ # in the caller's original job order.
805
+ #
806
+ # Sessions saved before fan-out support stored a bare Hash here.
803
807
  def replay_subagent_transcript(msg, ui)
804
- transcript = msg[:subagent_transcript]
805
- return unless transcript.is_a?(Hash)
808
+ stored = msg[:subagent_transcript]
809
+ Array(stored.is_a?(Hash) ? [stored] : stored).each do |transcript|
810
+ next unless transcript.is_a?(Hash)
811
+
812
+ replay_one_subagent_transcript(transcript, ui)
813
+ end
814
+ end
806
815
 
816
+ def replay_one_subagent_transcript(transcript, ui)
807
817
  events = transcript[:events] || transcript["events"] || []
808
818
  return if events.empty?
809
819
 
@@ -651,8 +651,7 @@ module Clacky
651
651
  # observe() attaches each trail to its own tool result message.
652
652
  # Kept out of the LLM payload via INTERNAL_FIELDS.
653
653
  if tool_call_id
654
- @pending_subagent_transcripts[tool_call_id] =
655
- extract_subagent_transcript(subagent, skill.identifier)
654
+ record_subagent_transcript(tool_call_id, subagent, skill.identifier)
656
655
  end
657
656
 
658
657
  # Mutate the subagent_instructions message in-place to become the result summary
@@ -13,7 +13,7 @@ module Clacky
13
13
  case @config.permission_mode
14
14
  when :auto_approve, :confirm_all
15
15
  # Both modes auto-execute all file/shell tools without confirmation.
16
- # The difference is only in request_user_feedback handling:
16
+ # The difference is only in ask_user handling:
17
17
  # auto_approve → no human present, inject auto_reply
18
18
  # confirm_all → human present, truly wait for user input
19
19
  true
@@ -55,10 +55,12 @@ module Clacky
55
55
  # todo_manager aliases
56
56
  "todo" => "todo_manager",
57
57
  "task_manager" => "todo_manager",
58
- # request_user_feedback aliases
59
- "ask_user" => "request_user_feedback",
60
- "user_feedback" => "request_user_feedback",
61
- "ask" => "request_user_feedback",
58
+ # ask_user aliases
59
+ "request_user_feedback" => "ask_user",
60
+ "user_feedback" => "ask_user",
61
+ "ask" => "ask_user",
62
+ "ask_question" => "ask_user",
63
+ "clarify" => "ask_user",
62
64
  # trash_manager aliases
63
65
  "trash" => "trash_manager",
64
66
  "delete" => "trash_manager",
data/lib/clacky/agent.rb CHANGED
@@ -108,7 +108,8 @@ module Clacky
108
108
  @ui = ui # UIController for direct UI interaction
109
109
  @debug_logs = [] # Debug logs for troubleshooting
110
110
  @pending_injections = [] # Pending inline skill injections to flush after observe()
111
- @pending_subagent_transcripts = {} # tool_call_id => subagent trail, attached by observe()
111
+ @pending_subagent_transcripts = {} # tool_call_id => [subagent trails], attached by observe()
112
+ @subagent_transcripts_mutex = Mutex.new # fan-out collects from worker threads
112
113
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
113
114
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
114
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)
@@ -688,12 +689,13 @@ module Clacky
688
689
 
689
690
  result = nil
690
691
  begin
691
- # Track if request_user_feedback was called
692
+ # Track if ask_user was called
692
693
  awaiting_user_feedback = false
693
694
  # Track if task was interrupted by user (denied tool execution)
694
695
  task_interrupted = false
695
696
 
696
697
  loop do
698
+ Clacky::Shutdown.checkpoint!
697
699
  @iterations += 1
698
700
  @hooks.trigger(:on_iteration, @iterations)
699
701
 
@@ -819,7 +821,7 @@ module Clacky
819
821
 
820
822
  # Show assistant message if there's content before tool calls
821
823
  if response[:content] && !response[:content].empty?
822
- emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
824
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true)
823
825
  end
824
826
 
825
827
  # Show token usage after assistant message (or immediately if no message).
@@ -829,7 +831,7 @@ module Clacky
829
831
  # Act: Execute tool calls
830
832
  action_result = act(response[:tool_calls])
831
833
 
832
- # Check if request_user_feedback was called
834
+ # Check if ask_user was called
833
835
  if action_result[:awaiting_feedback]
834
836
  awaiting_user_feedback = true
835
837
  observe(response, action_result[:tool_results])
@@ -866,7 +868,7 @@ module Clacky
866
868
  end
867
869
  end
868
870
 
869
- result = build_result
871
+ result = build_result(awaiting_user_feedback: awaiting_user_feedback)
870
872
 
871
873
  # Run skill evolution hooks after main loop completes
872
874
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
@@ -1226,10 +1228,10 @@ module Clacky
1226
1228
  end
1227
1229
  end
1228
1230
 
1229
- # Special handling for request_user_feedback
1231
+ # Special handling for the ask_user family
1230
1232
  # The interactive countdown (auto_approve) is handled after the tool
1231
1233
  # executes, once the question itself has been rendered to the user.
1232
- unless call[:name] == "request_user_feedback"
1234
+ unless Tools::AskUser.feedback_tool?(call[:name])
1233
1235
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
1234
1236
  end
1235
1237
 
@@ -1254,6 +1256,11 @@ module Clacky
1254
1256
  args[:tool_call_id] = call[:id]
1255
1257
  end
1256
1258
 
1259
+ # Same anchor for extension tools that fan out to their own subagents.
1260
+ if tool && tool.class.respond_to?(:receives_tool_call_id) && tool.class.receives_tool_call_id
1261
+ args[:tool_call_id] = call[:id]
1262
+ end
1263
+
1257
1264
  # Inject working_dir so tools don't rely on Dir.chdir global state
1258
1265
  args[:working_dir] = @working_dir if @working_dir
1259
1266
 
@@ -1319,10 +1326,13 @@ module Clacky
1319
1326
  @ui&.update_todos(@todos.dup) unless action == "list"
1320
1327
  end
1321
1328
 
1322
- # Special handling for request_user_feedback: emit as interactive feedback card
1323
- if call[:name] == "request_user_feedback"
1329
+ # Special handling for ask_user: emit as interactive feedback card.
1330
+ # A rejected call (no usable question) falls through to the normal
1331
+ # result path so the model sees the error and can retry.
1332
+ if Tools::AskUser.feedback_tool?(call[:name]) &&
1333
+ result.is_a?(Hash) && result[:awaiting_feedback]
1324
1334
  # Pass the raw call arguments to show_tool_call so the WebUI controller
1325
- # can extract question/context/options and emit a "request_feedback" event
1335
+ # can extract the questions and emit a "request_feedback" event
1326
1336
  # (renders as a clickable card in the browser).
1327
1337
  # Fallback UIs (terminal, IM channels) receive the formatted text message.
1328
1338
  @ui&.show_tool_call(call[:name], call[:arguments])
@@ -1456,23 +1466,25 @@ module Clacky
1456
1466
  end
1457
1467
  end
1458
1468
 
1459
- # Attach captured subagent transcripts (set by execute_skill_with_subagent)
1460
- # to their own invoke_skill tool result messages just appended by observe().
1461
- # Each transcript rides on its tool message as :subagent_transcript an
1462
- # internal field stripped before the LLM call but persisted to session.json
1463
- # and replayed to the WebUI. Keyed by tool_call_id so a turn with several
1464
- # invoke_skill calls attaches each subagent's trail to the call that spawned
1465
- # it. Consumed entries are deleted so each fires exactly once.
1469
+ # Attach captured subagent transcripts (set by execute_skill_with_subagent
1470
+ # and fan_out_labeled) to the tool result messages just appended by
1471
+ # observe(). Each transcript rides on its tool message as an internal field
1472
+ # stripped before the LLM call but persisted to session.json and replayed to
1473
+ # the WebUI. Keyed by tool_call_id so a turn with several such calls attaches
1474
+ # each trail to the call that spawned it. Consumed entries are deleted so
1475
+ # each fires exactly once.
1476
+ # A single subagent is just a batch of one, so both invoke_skill and
1477
+ # fan-out land in the same buffer and the same message field.
1466
1478
  private def attach_pending_subagent_transcripts(response)
1467
1479
  return if @pending_subagent_transcripts.empty?
1468
1480
 
1481
+ # Not filtered by tool name: any tool may spawn subagents, and the
1482
+ # tool_call_id it recorded under is already the anchor.
1469
1483
  Array(response[:tool_calls]).each do |tc|
1470
- next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
1484
+ batch = @subagent_transcripts_mutex.synchronize { @pending_subagent_transcripts.delete(tc[:id]) }
1485
+ next if batch.nil? || batch.empty?
1471
1486
 
1472
- transcript = @pending_subagent_transcripts.delete(tc[:id])
1473
- next unless transcript
1474
-
1475
- @history.attach_to_tool_result(tc[:id], :subagent_transcript, transcript)
1487
+ @history.attach_to_tool_result(tc[:id], :subagent_transcript, batch.sort_by { |t| t[:index] || 0 })
1476
1488
  end
1477
1489
  end
1478
1490
 
@@ -1491,6 +1503,12 @@ module Clacky
1491
1503
  TERMINAL_HEAD_CHARS = 40_000
1492
1504
  TERMINAL_TAIL_CHARS = 40_000
1493
1505
 
1506
+ # Per-subagent transcript budget. Mirrors the intent of
1507
+ # MessageHistory::MAX_EXT_EVENTS_PER_MESSAGE: milestones are worth keeping,
1508
+ # runaway trails are not. A fan-out stores one of these per job.
1509
+ MAX_TRANSCRIPT_EVENTS = 200
1510
+ MAX_TRANSCRIPT_BYTES = 64 * 1024
1511
+
1494
1512
  private def truncate_oversized_tool_content(msg, tool_name: nil)
1495
1513
  content = msg[:content]
1496
1514
  return msg unless content.is_a?(String) && content.length > MAX_TOOL_RESULT_CHARS
@@ -1586,7 +1604,7 @@ module Clacky
1586
1604
  !@start_time.nil?
1587
1605
  end
1588
1606
 
1589
- private def build_result(status = :success, error: nil)
1607
+ private def build_result(status = :success, error: nil, awaiting_user_feedback: false)
1590
1608
  task_iterations = @iterations - (@task_start_iterations || 0)
1591
1609
  task_cost = @total_cost - (@task_start_cost || 0)
1592
1610
 
@@ -1601,7 +1619,8 @@ module Clacky
1601
1619
  cost_source: @task_cost_source,
1602
1620
  cache_stats: @task_cache_stats || @cache_stats,
1603
1621
  history: @history,
1604
- error: error
1622
+ error: error,
1623
+ awaiting_user_feedback: awaiting_user_feedback
1605
1624
  }
1606
1625
  end
1607
1626
 
@@ -1640,7 +1659,7 @@ module Clacky
1640
1659
  @tool_registry.register(Tools::WebSearch.new)
1641
1660
  @tool_registry.register(Tools::WebFetch.new)
1642
1661
  @tool_registry.register(Tools::TodoManager.new)
1643
- @tool_registry.register(Tools::RequestUserFeedback.new)
1662
+ @tool_registry.register(Tools::AskUser.new)
1644
1663
  @tool_registry.register(Tools::InvokeSkill.new)
1645
1664
  @tool_registry.register(Tools::Browser.new)
1646
1665
  end
@@ -1719,11 +1738,16 @@ module Clacky
1719
1738
  # the jobs over — forking deep-copies parent config + history, which must
1720
1739
  # not race. Only the blocking run belongs in the lambda.
1721
1740
  #
1722
- # @param jobs [Array<Hash>] each { label: String, run: #call }
1741
+ # Pass :subagent alongside :run (and a tool_call_id) to have each job's
1742
+ # message trail persisted onto the tool result, so the WebUI can replay the
1743
+ # whole batch after a reload instead of just the collapsed return values.
1744
+ #
1745
+ # @param jobs [Array<Hash>] each { label: String, run: #call, subagent: Agent (optional) }
1723
1746
  # @param max_concurrency [Integer] jobs allowed to run at once
1724
1747
  # @param timeout [Numeric, nil] wall-clock budget for the whole batch
1748
+ # @param tool_call_id [String, nil] anchors persisted transcripts to this tool call
1725
1749
  # @return [Array<Fanout::Result>] aligned to the input order
1726
- def fan_out_labeled(jobs, max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil)
1750
+ def fan_out_labeled(jobs, max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil, tool_call_id: nil)
1727
1751
  return [] if jobs.empty?
1728
1752
 
1729
1753
  # Fanout workers are fresh threads, so the epoch that lets the web
@@ -1734,11 +1758,18 @@ module Clacky
1734
1758
  wrapped = jobs.each_with_index.map do |job, index|
1735
1759
  label = job[:label] || job["label"] || "Subagent #{index + 1}/#{jobs.size}"
1736
1760
  run = job[:run] || job["run"]
1761
+ subagent = job[:subagent] || job["subagent"]
1737
1762
  raise ArgumentError, "job #{index} must provide a callable :run" unless run.respond_to?(:call)
1738
1763
 
1739
1764
  lambda do
1740
1765
  Thread.current[:task_epoch] = epoch
1741
- within_phase(label, kind: "fanout_subagent", concurrent: true) { run.call }
1766
+ begin
1767
+ within_phase(label, kind: "fanout_subagent", concurrent: true) { run.call }
1768
+ ensure
1769
+ # Runs in ensure so a job that raised still leaves a trail — a failed
1770
+ # subagent is exactly the one worth inspecting afterwards.
1771
+ record_subagent_transcript(tool_call_id, subagent, label, index: index) if subagent
1772
+ end
1742
1773
  end
1743
1774
  end
1744
1775
 
@@ -1751,6 +1782,21 @@ module Clacky
1751
1782
  @ui.with_phase(kind: kind, label: label, concurrent: concurrent, &block)
1752
1783
  end
1753
1784
 
1785
+ # Called from fan-out worker threads, hence the mutex. Slots are keyed by
1786
+ # job index so the persisted order matches the caller's job order rather
1787
+ # than completion order.
1788
+ def record_subagent_transcript(tool_call_id, subagent, label, index: 0)
1789
+ return unless tool_call_id
1790
+
1791
+ transcript = extract_subagent_transcript(subagent, label)
1792
+ transcript[:index] = index
1793
+ @subagent_transcripts_mutex.synchronize do
1794
+ (@pending_subagent_transcripts[tool_call_id] ||= []) << transcript
1795
+ end
1796
+ rescue StandardError => e
1797
+ Clacky::Logger.warn("agent.subagent_transcript_failed", error: e.message, label: label)
1798
+ end
1799
+
1754
1800
  # The subagent's last non-empty assistant message — its actual answer.
1755
1801
  #
1756
1802
  # A subagent's `run` result carries cost and iteration counts but no reply
@@ -1986,10 +2032,34 @@ module Clacky
1986
2032
  skill: skill_identifier,
1987
2033
  iterations: subagent.iterations,
1988
2034
  cost_usd: subagent.total_cost.round(4),
1989
- events: events
2035
+ events: cap_transcript_events(events)
1990
2036
  }
1991
2037
  end
1992
2038
 
2039
+ # session.json is rewritten in full on every save, so a transcript has to
2040
+ # stay bounded — a fan-out of chatty subagents would otherwise multiply an
2041
+ # unbounded trail by the batch size. Oldest events are dropped first: the
2042
+ # tail is what explains how the subagent ended up where it did.
2043
+ private def cap_transcript_events(events)
2044
+ kept = events.last(MAX_TRANSCRIPT_EVENTS)
2045
+ dropped = events.size - kept.size
2046
+
2047
+ budget = MAX_TRANSCRIPT_BYTES
2048
+ kept = kept.reverse.take_while do |entry|
2049
+ budget -= transcript_entry_bytes(entry)
2050
+ budget.positive?
2051
+ end.reverse
2052
+ dropped = events.size - kept.size
2053
+
2054
+ return kept if dropped.zero?
2055
+
2056
+ [{ role: "system", content: "[#{dropped} earlier event(s) omitted]" }] + kept
2057
+ end
2058
+
2059
+ private def transcript_entry_bytes(entry)
2060
+ entry[:content].to_s.bytesize + Array(entry[:tool_calls]).sum { |tc| tc[:arguments].to_s.bytesize }
2061
+ end
2062
+
1993
2063
  # Deep clone helper for messages using Marshal
1994
2064
  # @param obj [Object] Object to clone
1995
2065
  # @return [Object] Deep cloned object
@@ -2119,7 +2189,10 @@ module Clacky
2119
2189
  begin
2120
2190
  Clacky::Vision::Resolver.new(ocr_entry).describe(image)
2121
2191
  ensure
2122
- @ui&.show_progress(phase: "done")
2192
+ # Must pass progress_type: "vision" — the UI's legacy shim pairs
2193
+ # active/done by type, so a bare done would leave the OCR spinner
2194
+ # frozen forever (same trap as the old retrying-slot bug).
2195
+ @ui&.show_progress(progress_type: "vision", phase: "done")
2123
2196
  end
2124
2197
  end
2125
2198
 
@@ -2327,7 +2400,7 @@ module Clacky
2327
2400
  # and cannot load file:// directly) and must stay scoped to the Web UI
2328
2401
  # controller. IM channel subscribers need the original file:// markdown so
2329
2402
  # parse_file_links can extract paths and deliver images as native attachments.
2330
- private def emit_assistant_message(content, reasoning_content: nil)
2403
+ private def emit_assistant_message(content, reasoning_content: nil, interim: false)
2331
2404
  # Prepend reasoning/thinking content (from thinking-mode providers like
2332
2405
  # DeepSeek V4, Kimi K2) wrapped in <think> tags so the Web UI renders it
2333
2406
  # as a collapsible thinking block (see sessions.js _renderMarkdown).
@@ -2340,7 +2413,7 @@ module Clacky
2340
2413
  return if full_content.nil? || full_content.to_s.strip.empty?
2341
2414
 
2342
2415
  parsed = parse_file_links(content)
2343
- @ui&.show_assistant_message(full_content, files: parsed[:files])
2416
+ @ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim)
2344
2417
  end
2345
2418
 
2346
2419
  # Record BEFORE-change snapshots for any file a tool is about to mutate,
@@ -519,7 +519,7 @@ module Clacky
519
519
  return nil if activated?
520
520
  return nil if ENV["CLACKY_TEST"] == "1"
521
521
 
522
- Thread.new do
522
+ Clacky::ThreadRegistry.spawn(name: "brand-fetch-free-skills") do
523
523
  Thread.current.abort_on_exception = false
524
524
 
525
525
  begin
@@ -922,7 +922,7 @@ module Clacky
922
922
  return nil unless activated?
923
923
  return nil if ENV["CLACKY_TEST"] == "1"
924
924
 
925
- Thread.new do
925
+ Clacky::ThreadRegistry.spawn(name: "brand-fetch-extensions") do
926
926
  Thread.current.abort_on_exception = false
927
927
 
928
928
  begin
@@ -1305,7 +1305,7 @@ module Clacky
1305
1305
  return nil unless activated?
1306
1306
  return nil if ENV["CLACKY_TEST"] == "1"
1307
1307
 
1308
- Thread.new do
1308
+ Clacky::ThreadRegistry.spawn(name: "brand-fetch-skills") do
1309
1309
  Thread.current.abort_on_exception = false
1310
1310
 
1311
1311
  begin
@@ -1890,26 +1890,24 @@ module Clacky
1890
1890
  return cached[:key] if key_valid && within_grace
1891
1891
  end
1892
1892
 
1893
- # Guard: @device_id must match the value recorded in activated_devices on the
1894
- # server. If it is nil (e.g. loaded from a brand.yml that predates the
1895
- # device_id field), reload from disk as a last-chance recovery the file
1896
- # may have been written by a concurrent process or a newer gem version.
1897
- # If still nil after reload, raise an actionable error rather than sending
1898
- # an empty device_id that will always be rejected by the server.
1899
- if @device_id.nil? || @device_id.strip.empty?
1900
- reloaded = BrandConfig.load
1901
- @device_id = reloaded.device_id if reloaded.device_id && !reloaded.device_id.strip.empty?
1902
- end
1893
+ # Single disk read so device_id and license_key share one snapshot.
1894
+ # Reading license_key live (not the instance snapshot) is what lets a
1895
+ # license renewed mid-session be picked up on retrya live agent still
1896
+ # holds the expired key and the server would otherwise reject the
1897
+ # signature as "license expired".
1898
+ reloaded = BrandConfig.load
1899
+ @device_id = reloaded.device_id if @device_id.nil? || @device_id.strip.empty?
1903
1900
  raise "Device ID is missing. Please re-activate your license with `clacky license activate`." \
1904
1901
  if @device_id.nil? || @device_id.strip.empty?
1905
1902
 
1906
- # Build signed request payload
1907
- user_id = parse_user_id_from_key(@license_key)
1908
- key_hash = Digest::SHA256.hexdigest(@license_key)
1903
+ license_key = reloaded.license_key
1904
+ license_key = @license_key if license_key.nil? || license_key.strip.empty?
1905
+ user_id = parse_user_id_from_key(license_key)
1906
+ key_hash = Digest::SHA256.hexdigest(license_key)
1909
1907
  ts = Time.now.utc.to_i.to_s
1910
1908
  nonce = SecureRandom.hex(16)
1911
1909
  message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
1912
- signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
1910
+ signature = OpenSSL::HMAC.hexdigest("SHA256", license_key, message)
1913
1911
 
1914
1912
  payload = {
1915
1913
  key_hash: key_hash,
data/lib/clacky/cli.rb CHANGED
@@ -419,7 +419,7 @@ module Clacky
419
419
 
420
420
  if brand.heartbeat_due?
421
421
  Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
422
- Thread.new do
422
+ Clacky::ThreadRegistry.spawn(name: "cli-brand-heartbeat") do
423
423
  begin
424
424
  result = brand.heartbeat!
425
425
  if result[:success]
@@ -1053,7 +1053,7 @@ module Clacky
1053
1053
  auto_name_session(agent, input)
1054
1054
 
1055
1055
  # Run agent in background thread
1056
- current_task_thread = Thread.new do
1056
+ current_task_thread = Clacky::ThreadRegistry.spawn(name: "cli-agent-task") do
1057
1057
  begin
1058
1058
  # Set status to working when agent starts
1059
1059
  ui_controller.set_working_status