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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +9 -0
- data/README.md +2 -2
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +136 -22
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +113 -23
- data/lib/clacky/agent_config.rb +40 -18
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -1
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
- data/lib/clacky/default_extensions/general/ext.yml +1 -1
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/extension/api_loader.rb +4 -11
- data/lib/clacky/extension/packager.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/media/generator.rb +3 -3
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +33 -2
- data/lib/clacky/providers.rb +13 -6
- data/lib/clacky/server/http_server.rb +102 -98
- data/lib/clacky/server/session_registry.rb +11 -15
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +21 -12
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/ui2/components/inline_input.rb +1 -0
- data/lib/clacky/ui2/components/input_area.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +0 -1
- data/lib/clacky/ui2/components/todo_area.rb +1 -0
- data/lib/clacky/ui2/line_editor.rb +1 -0
- data/lib/clacky/ui2/markdown_renderer.rb +5 -1
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +207 -6
- data/lib/clacky/ui_interface.rb +13 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/model_pricing.rb +21 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +144 -213
- data/lib/clacky/web/components/chat-navigator.js +1 -1
- data/lib/clacky/web/features/billing/store.js +3 -3
- data/lib/clacky/web/features/billing/view.js +5 -61
- data/lib/clacky/web/features/extensions/store.js +1 -1
- data/lib/clacky/web/features/extensions/view.js +28 -5
- data/lib/clacky/web/features/new-session/view.js +43 -2
- data/lib/clacky/web/i18n.js +38 -22
- data/lib/clacky/web/index.html +19 -24
- data/lib/clacky/web/projects.js +96 -279
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +7 -1
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "tty-
|
|
3
|
+
require "tty-screen"
|
|
4
4
|
require_relative "theme_manager"
|
|
5
5
|
|
|
6
6
|
module Clacky
|
|
@@ -14,6 +14,10 @@ module Clacky
|
|
|
14
14
|
def render(content)
|
|
15
15
|
return content if content.nil? || content.empty?
|
|
16
16
|
|
|
17
|
+
# tty-markdown pulls in rouge + kramdown (~0.4s). Server mode renders
|
|
18
|
+
# markdown in the browser, so only pay that cost on a real terminal render.
|
|
19
|
+
require "tty-markdown" unless defined?(TTY::Markdown)
|
|
20
|
+
|
|
17
21
|
# Get current theme colors
|
|
18
22
|
theme = ThemeManager.current_theme
|
|
19
23
|
|
|
@@ -131,6 +131,7 @@ module Clacky
|
|
|
131
131
|
@metadata = {}
|
|
132
132
|
@last_chunk_at = nil
|
|
133
133
|
@monitor = Monitor.new
|
|
134
|
+
@closed_cond = @monitor.new_cond
|
|
134
135
|
end
|
|
135
136
|
|
|
136
137
|
# Start rendering. Registers with the owner (allocating an entry id
|
|
@@ -185,7 +186,10 @@ module Clacky
|
|
|
185
186
|
snapshot = @monitor.synchronize do
|
|
186
187
|
return if @unregistered
|
|
187
188
|
first_close = @state == :running
|
|
188
|
-
|
|
189
|
+
if first_close
|
|
190
|
+
@state = :closed
|
|
191
|
+
@closed_cond.broadcast
|
|
192
|
+
end
|
|
189
193
|
{
|
|
190
194
|
first_close: first_close,
|
|
191
195
|
message: final_message || @message,
|
|
@@ -205,6 +209,21 @@ module Clacky
|
|
|
205
209
|
end
|
|
206
210
|
alias_method :cancel, :finish
|
|
207
211
|
|
|
212
|
+
# Close without leaving any trace: the owner removes the entry instead
|
|
213
|
+
# of committing a final frame. For work whose outcome was "nothing to
|
|
214
|
+
# report", where a lingering line is pure noise.
|
|
215
|
+
def discard
|
|
216
|
+
@monitor.synchronize do
|
|
217
|
+
return if @unregistered
|
|
218
|
+
@state = :closed
|
|
219
|
+
@closed_cond.broadcast
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
stop_ticker
|
|
223
|
+
@owner.unregister_progress(self, final_frame: nil)
|
|
224
|
+
@monitor.synchronize { @unregistered = true }
|
|
225
|
+
end
|
|
226
|
+
|
|
208
227
|
# True while the ticker thread is alive.
|
|
209
228
|
def ticker_alive?
|
|
210
229
|
t = @ticker
|
|
@@ -261,8 +280,13 @@ module Clacky
|
|
|
261
280
|
Thread.current.name = "progress-ticker-#{object_id}"
|
|
262
281
|
begin
|
|
263
282
|
loop do
|
|
264
|
-
sleep
|
|
265
|
-
|
|
283
|
+
# wait, not sleep: finish/discard signals the condition so a
|
|
284
|
+
# long tick_interval never delays shutdown.
|
|
285
|
+
closed = @monitor.synchronize do
|
|
286
|
+
@closed_cond.wait(@tick_interval) if @state == :running
|
|
287
|
+
@state != :running
|
|
288
|
+
end
|
|
289
|
+
break if closed
|
|
266
290
|
render_now
|
|
267
291
|
end
|
|
268
292
|
rescue StandardError
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pastel"
|
|
3
4
|
require_relative "layout_manager"
|
|
4
5
|
require_relative "view_renderer"
|
|
5
6
|
require_relative "progress_handle"
|
|
@@ -226,6 +227,9 @@ module Clacky
|
|
|
226
227
|
# trigger a full output repaint (visible flicker) and the visual
|
|
227
228
|
# order would have business messages appearing below the spinner.
|
|
228
229
|
def append_output(content)
|
|
230
|
+
return nil if Thread.current[:clacky_chore_phase]
|
|
231
|
+
|
|
232
|
+
content = prefix_with_phase_label(content)
|
|
229
233
|
@progress_mutex.synchronize do
|
|
230
234
|
top = @progress_stack.last
|
|
231
235
|
if top && top.entry_id
|
|
@@ -249,6 +253,17 @@ module Clacky
|
|
|
249
253
|
@layout.append_output(content)
|
|
250
254
|
end
|
|
251
255
|
|
|
256
|
+
# With several phases running at once the CLI's line-oriented output
|
|
257
|
+
# interleaves, so each line needs its origin stamped on it. A single
|
|
258
|
+
# active phase reads fine unprefixed (the banners already delimit it).
|
|
259
|
+
private def prefix_with_phase_label(content)
|
|
260
|
+
label = Thread.current[:clacky_phase_label]
|
|
261
|
+
return content unless label
|
|
262
|
+
|
|
263
|
+
tag = "[#{label}] "
|
|
264
|
+
content.to_s.split("\n", -1).map { |line| line.empty? ? line : "#{tag}#{line}" }.join("\n")
|
|
265
|
+
end
|
|
266
|
+
|
|
252
267
|
# Log message to output area (use instead of puts)
|
|
253
268
|
# @param message [String] Message to log
|
|
254
269
|
# @param level [Symbol] Log level (:debug, :info, :warning, :error)
|
|
@@ -392,6 +407,8 @@ module Clacky
|
|
|
392
407
|
# @param path [String] File path
|
|
393
408
|
# @param is_new_file [Boolean] Whether this is a new file
|
|
394
409
|
def show_file_write_preview(path, is_new_file:)
|
|
410
|
+
return if chore_capture(:file, path)
|
|
411
|
+
|
|
395
412
|
theme = ThemeManager.current_theme
|
|
396
413
|
file_label = theme.format_symbol(:file)
|
|
397
414
|
status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
|
|
@@ -402,6 +419,8 @@ module Clacky
|
|
|
402
419
|
# Show file operation preview (Edit tool)
|
|
403
420
|
# @param path [String] File path
|
|
404
421
|
def show_file_edit_preview(path)
|
|
422
|
+
return if chore_capture(:file, path)
|
|
423
|
+
|
|
405
424
|
theme = ThemeManager.current_theme
|
|
406
425
|
file_label = theme.format_symbol(:file)
|
|
407
426
|
append_output("\n#{file_label} #{path || '(unknown)'}")
|
|
@@ -410,6 +429,8 @@ module Clacky
|
|
|
410
429
|
# Show file operation error
|
|
411
430
|
# @param error_message [String] Error message
|
|
412
431
|
def show_file_error(error_message)
|
|
432
|
+
return if chore_capture(:error, error_message)
|
|
433
|
+
|
|
413
434
|
theme = ThemeManager.current_theme
|
|
414
435
|
append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
|
|
415
436
|
end
|
|
@@ -453,10 +474,69 @@ module Clacky
|
|
|
453
474
|
filtered.strip
|
|
454
475
|
end
|
|
455
476
|
|
|
477
|
+
# Record a noteworthy event that happened inside a chore phase. The raw
|
|
478
|
+
# transcript is suppressed (a line-oriented terminal has no folding),
|
|
479
|
+
# but the CLI still owes the user a digest — the collapsed-card
|
|
480
|
+
# equivalent of what the web UI lets you expand.
|
|
481
|
+
#
|
|
482
|
+
# @return [Boolean] true if a chore phase absorbed it.
|
|
483
|
+
private def chore_capture(kind, text)
|
|
484
|
+
return false unless Thread.current[:clacky_chore_phase]
|
|
485
|
+
text = text.to_s.strip
|
|
486
|
+
return true if text.empty?
|
|
487
|
+
|
|
488
|
+
@phase_mutex ||= Mutex.new
|
|
489
|
+
@phase_mutex.synchronize do
|
|
490
|
+
@chore_digest ||= { tools: Hash.new(0), files: [], errors: [] }
|
|
491
|
+
case kind
|
|
492
|
+
when :tool then @chore_digest[:tools][text] += 1
|
|
493
|
+
when :file then @chore_digest[:files] << text unless @chore_digest[:files].include?(text)
|
|
494
|
+
when :error then @chore_digest[:errors] << text
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
true
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Turn the captured digest into the handful of lines the CLI prints
|
|
501
|
+
# after a chore finishes. Errors are always listed in full; tools and
|
|
502
|
+
# files are condensed, since a memory update touching 20 files should
|
|
503
|
+
# not out-scroll the answer the user actually asked for.
|
|
504
|
+
private def chore_digest_lines
|
|
505
|
+
@phase_mutex ||= Mutex.new
|
|
506
|
+
digest = @phase_mutex.synchronize { @chore_digest }
|
|
507
|
+
return [] if digest.nil?
|
|
508
|
+
|
|
509
|
+
lines = []
|
|
510
|
+
digest[:files].first(CHORE_DIGEST_MAX_FILES).each { |f| lines << "· #{f}" }
|
|
511
|
+
if (extra = digest[:files].size - CHORE_DIGEST_MAX_FILES) > 0
|
|
512
|
+
lines << "· …#{extra} more file#{"s" if extra > 1}"
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
if digest[:tools].any?
|
|
516
|
+
total = digest[:tools].values.sum
|
|
517
|
+
ranked = digest[:tools].sort_by { |_n, c| -c }
|
|
518
|
+
names = ranked.first(CHORE_DIGEST_MAX_TOOLS).map do |name, count|
|
|
519
|
+
count > 1 ? "#{name}×#{count}" : name
|
|
520
|
+
end
|
|
521
|
+
if (extra = ranked.size - CHORE_DIGEST_MAX_TOOLS) > 0
|
|
522
|
+
names << "+#{extra} more"
|
|
523
|
+
end
|
|
524
|
+
lines << "· #{total} tool call#{"s" if total > 1}: #{names.join(", ")}"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
digest[:errors].first(CHORE_DIGEST_MAX_ERRORS).each { |e| lines << "! #{e}" }
|
|
528
|
+
if (extra = digest[:errors].size - CHORE_DIGEST_MAX_ERRORS) > 0
|
|
529
|
+
lines << "! …#{extra} more error#{"s" if extra > 1}"
|
|
530
|
+
end
|
|
531
|
+
lines
|
|
532
|
+
end
|
|
533
|
+
|
|
456
534
|
# Show tool call
|
|
457
535
|
# @param name [String] Tool name
|
|
458
536
|
# @param args [String, Hash] Tool arguments (JSON string or Hash)
|
|
459
537
|
def show_tool_call(name, args)
|
|
538
|
+
return if chore_capture(:tool, name)
|
|
539
|
+
|
|
460
540
|
# Reset stdout buffer on each new tool call so previous command output
|
|
461
541
|
# doesn't bleed into the next one, and so the buffer is ready before
|
|
462
542
|
# on_output starts firing (which can happen before show_progress is called).
|
|
@@ -506,6 +586,8 @@ module Clacky
|
|
|
506
586
|
# @param error [String, Exception] Error message or exception
|
|
507
587
|
def show_tool_error(error)
|
|
508
588
|
error_msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
589
|
+
return if chore_capture(:error, error_msg)
|
|
590
|
+
|
|
509
591
|
output = @renderer.render_tool_error(error: error_msg)
|
|
510
592
|
append_output(output)
|
|
511
593
|
end
|
|
@@ -655,6 +737,22 @@ module Clacky
|
|
|
655
737
|
|
|
656
738
|
# Called by ProgressHandle#start.
|
|
657
739
|
def register_progress(handle)
|
|
740
|
+
# Inside a chore phase only the phase's own indicator occupies a line.
|
|
741
|
+
# Nested handles (the subagent's LLM calls and tool runs) get no entry
|
|
742
|
+
# of their own — otherwise each one commits a permanent final frame on
|
|
743
|
+
# finish, since unregister_progress writes straight into the buffer and
|
|
744
|
+
# never passes through append_output's chore gate. Their activity is
|
|
745
|
+
# instead folded into the chore line by +fold_into_chore_line+.
|
|
746
|
+
if Thread.current[:clacky_chore_phase] && @chore_progress
|
|
747
|
+
@progress_mutex.synchronize do
|
|
748
|
+
@chore_nested ||= []
|
|
749
|
+
@chore_nested << handle
|
|
750
|
+
@chore_steps = @chore_steps.to_i + 1 if handle.style == :primary
|
|
751
|
+
fold_into_chore_line(handle)
|
|
752
|
+
end
|
|
753
|
+
return nil
|
|
754
|
+
end
|
|
755
|
+
|
|
658
756
|
@progress_mutex.synchronize do
|
|
659
757
|
prev_top = @progress_stack.last
|
|
660
758
|
if prev_top
|
|
@@ -675,6 +773,17 @@ module Clacky
|
|
|
675
773
|
# Called by ProgressHandle#finish.
|
|
676
774
|
def unregister_progress(handle, final_frame:)
|
|
677
775
|
@progress_mutex.synchronize do
|
|
776
|
+
if @chore_nested&.delete(handle)
|
|
777
|
+
# Folded handles own no entry; hand the chore line back to
|
|
778
|
+
# whatever is still running underneath (or the bare label).
|
|
779
|
+
fold_into_chore_line(@chore_nested.last)
|
|
780
|
+
next
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# A handle that was never stacked owns nothing here: restoring
|
|
784
|
+
# "the new top" would duplicate what is legitimately on screen.
|
|
785
|
+
next unless @progress_stack.include?(handle)
|
|
786
|
+
|
|
678
787
|
if handle.entry_id
|
|
679
788
|
if final_frame && !final_frame.to_s.strip.empty?
|
|
680
789
|
update_entry(handle.entry_id, @renderer.render_progress(final_frame))
|
|
@@ -705,6 +814,19 @@ module Clacky
|
|
|
705
814
|
# +register_progress+).
|
|
706
815
|
def render_frame(handle, frame)
|
|
707
816
|
@progress_mutex.synchronize do
|
|
817
|
+
if @chore_nested&.include?(handle)
|
|
818
|
+
fold_into_chore_line(handle)
|
|
819
|
+
return
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# The chore's own ticker must render the folded view too, otherwise
|
|
823
|
+
# every tick repaints the bare label and the line flickers between
|
|
824
|
+
# "<label>…" and "<label> · <activity>…".
|
|
825
|
+
if @chore_progress && @chore_progress == handle && @chore_nested&.any?
|
|
826
|
+
fold_into_chore_line(@chore_nested.last)
|
|
827
|
+
return
|
|
828
|
+
end
|
|
829
|
+
|
|
708
830
|
return unless @progress_stack.last == handle
|
|
709
831
|
return unless handle.entry_id
|
|
710
832
|
|
|
@@ -726,6 +848,27 @@ module Clacky
|
|
|
726
848
|
end
|
|
727
849
|
end
|
|
728
850
|
|
|
851
|
+
# Repaint the chore phase's single line to reflect the innermost nested
|
|
852
|
+
# activity: "<label> · <what it's doing> (Ns · step K)". Keeps the user
|
|
853
|
+
# informed without letting the chore's subagent spam one permanent line
|
|
854
|
+
# per LLM call. Must be called with @progress_mutex held.
|
|
855
|
+
private def fold_into_chore_line(inner)
|
|
856
|
+
chore = @chore_progress
|
|
857
|
+
return unless chore&.entry_id
|
|
858
|
+
|
|
859
|
+
parts = [chore.message]
|
|
860
|
+
parts << inner.message.to_s.strip if inner && !inner.message.to_s.strip.empty?
|
|
861
|
+
|
|
862
|
+
suffix = []
|
|
863
|
+
elapsed = chore.start_time ? (Time.now - chore.start_time).to_i : 0
|
|
864
|
+
suffix << "#{elapsed}s" if elapsed > 0
|
|
865
|
+
suffix << "step #{@chore_steps}" if @chore_steps.to_i > 0
|
|
866
|
+
|
|
867
|
+
line = parts.join(" · ")
|
|
868
|
+
line = "#{line}… (#{suffix.join(" · ")})" unless suffix.empty?
|
|
869
|
+
update_entry(chore.entry_id, @renderer.render_progress(line))
|
|
870
|
+
end
|
|
871
|
+
|
|
729
872
|
# Render the very first frame of +handle+ (used when registering or
|
|
730
873
|
# restoring). Mirrors +render_frame+'s formatting minus the implicit
|
|
731
874
|
# elapsed-time tick (handle hasn't ticked yet).
|
|
@@ -888,28 +1031,86 @@ module Clacky
|
|
|
888
1031
|
append_output(output)
|
|
889
1032
|
end
|
|
890
1033
|
|
|
891
|
-
|
|
1034
|
+
# Phases the user never asked for: post-task chores that run after the
|
|
1035
|
+
# answer is already on screen. The web UI folds them into a card; a
|
|
1036
|
+
# line-oriented terminal has no folding, so their transcript is
|
|
1037
|
+
# swallowed and only a one-line progress indicator remains.
|
|
1038
|
+
CHORE_PHASE_KINDS = %w[memory_update skill_evolution].freeze
|
|
1039
|
+
|
|
1040
|
+
# Caps for the post-chore digest. A chore is background work the user
|
|
1041
|
+
# never asked for, so its recap must stay far shorter than the answer.
|
|
1042
|
+
CHORE_DIGEST_MAX_FILES = 5
|
|
1043
|
+
CHORE_DIGEST_MAX_TOOLS = 4
|
|
1044
|
+
CHORE_DIGEST_MAX_ERRORS = 3
|
|
1045
|
+
|
|
1046
|
+
def phase_start(kind:, label:, concurrent: false)
|
|
892
1047
|
phase_id = SecureRandom.uuid
|
|
893
|
-
|
|
894
|
-
@
|
|
1048
|
+
chore = CHORE_PHASE_KINDS.include?(kind.to_s) && !verbose_phases?
|
|
1049
|
+
@phase_mutex ||= Mutex.new
|
|
1050
|
+
@phase_mutex.synchronize do
|
|
1051
|
+
@active_phases ||= {}
|
|
1052
|
+
@active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now, chore: chore }
|
|
1053
|
+
end
|
|
895
1054
|
Thread.current[:clacky_phase_id] = phase_id
|
|
896
1055
|
|
|
1056
|
+
if chore
|
|
1057
|
+
# Start the indicator before arming the gate: this handle is the
|
|
1058
|
+
# one thing that stays visible during the chore.
|
|
1059
|
+
@chore_progress = start_progress(message: label, style: :quiet)
|
|
1060
|
+
Thread.current[:clacky_chore_phase] = phase_id
|
|
1061
|
+
return phase_id
|
|
1062
|
+
end
|
|
1063
|
+
|
|
897
1064
|
banner = "──────── ▼ #{label} ────────"
|
|
898
1065
|
append_output(@renderer.render_system_message(banner, prefix_newline: true))
|
|
1066
|
+
# Only concurrent phases interleave, so only they need per-line tagging.
|
|
1067
|
+
Thread.current[:clacky_phase_label] = concurrent ? label : nil
|
|
899
1068
|
phase_id
|
|
900
1069
|
end
|
|
901
1070
|
|
|
902
1071
|
def phase_end(phase_id, summary: nil)
|
|
903
|
-
Thread.current[:clacky_phase_id] = nil
|
|
904
|
-
|
|
1072
|
+
Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
|
|
1073
|
+
Thread.current[:clacky_phase_label] = nil
|
|
1074
|
+
@phase_mutex ||= Mutex.new
|
|
1075
|
+
info = @phase_mutex.synchronize { @active_phases&.delete(phase_id) }
|
|
1076
|
+
return unless info
|
|
905
1077
|
|
|
906
|
-
info = @active_phases.delete(phase_id)
|
|
907
1078
|
label = info[:label]
|
|
1079
|
+
|
|
1080
|
+
if info[:chore]
|
|
1081
|
+
Thread.current[:clacky_chore_phase] = nil
|
|
1082
|
+
handle = @chore_progress
|
|
1083
|
+
@chore_progress = nil
|
|
1084
|
+
@chore_nested = nil
|
|
1085
|
+
@chore_steps = 0
|
|
1086
|
+
handle&.discard
|
|
1087
|
+
|
|
1088
|
+
digest = chore_digest_lines
|
|
1089
|
+
@phase_mutex.synchronize { @chore_digest = nil }
|
|
1090
|
+
|
|
1091
|
+
# No summary and nothing captured means nothing worth reporting
|
|
1092
|
+
# happened (the common "no memory updates needed" path) — leave no
|
|
1093
|
+
# trace at all.
|
|
1094
|
+
has_summary = summary && !summary.to_s.strip.empty?
|
|
1095
|
+
return if !has_summary && digest.empty?
|
|
1096
|
+
|
|
1097
|
+
elapsed = (Time.now - info[:started_at]).round
|
|
1098
|
+
head = has_summary ? "#{label} — #{summary.to_s.strip} (#{elapsed}s)" : "#{label} (#{elapsed}s)"
|
|
1099
|
+
append_output(@renderer.render_system_message(head, prefix_newline: false))
|
|
1100
|
+
theme = ThemeManager.current_theme
|
|
1101
|
+
digest.each { |line| append_output(theme.format_text(" #{line}", :thinking)) }
|
|
1102
|
+
return
|
|
1103
|
+
end
|
|
1104
|
+
|
|
908
1105
|
tail = summary && !summary.to_s.strip.empty? ? " — #{summary.to_s.strip}" : ""
|
|
909
1106
|
banner = "──────── ▲ #{label} done#{tail} ────────"
|
|
910
1107
|
append_output(@renderer.render_system_message(banner, prefix_newline: false))
|
|
911
1108
|
end
|
|
912
1109
|
|
|
1110
|
+
private def verbose_phases?
|
|
1111
|
+
@config.respond_to?(:verbose) && @config.verbose
|
|
1112
|
+
end
|
|
1113
|
+
|
|
913
1114
|
# Set workspace status to idle (called when agent stops working)
|
|
914
1115
|
def set_idle_status
|
|
915
1116
|
# Safety net: close any legacy progress slots that were opened via
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -159,19 +159,29 @@ module Clacky
|
|
|
159
159
|
# === Lifecycle ===
|
|
160
160
|
def stop(clear_screen: false); end
|
|
161
161
|
|
|
162
|
+
# === Custom events (extension bus) ===
|
|
163
|
+
# Push an arbitrary typed event to the UI transport. Streaming UIs (web,
|
|
164
|
+
# json) override this; UIs with no event channel (CLI, null) drop it.
|
|
165
|
+
#
|
|
166
|
+
# Extensions must namespace `type` as "ext.<extension>.<event>" so custom
|
|
167
|
+
# events never collide with the built-in protocol. The web UI forwards
|
|
168
|
+
# namespaced events straight to the `Clacky.ext` bus, where a subscriber
|
|
169
|
+
# receives them under that same name.
|
|
170
|
+
def emit(type, **data); end
|
|
171
|
+
|
|
162
172
|
# === Phase grouping (optional, web UI uses this to fold subagent runs) ===
|
|
163
173
|
# Begin a logical phase. Events emitted between phase_start and phase_end
|
|
164
174
|
# carry the phase_id so the UI can group them visually.
|
|
165
175
|
# Returns the phase_id (caller is responsible for passing it to phase_end).
|
|
166
|
-
def phase_start(kind:, label: nil)
|
|
176
|
+
def phase_start(kind:, label: nil, concurrent: false)
|
|
167
177
|
SecureRandom.uuid
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
def phase_end(phase_id, summary: nil); end
|
|
171
181
|
|
|
172
182
|
# Run block within a phase. Always closes via ensure.
|
|
173
|
-
def with_phase(kind:, label: nil)
|
|
174
|
-
pid = phase_start(kind: kind, label: label)
|
|
183
|
+
def with_phase(kind:, label: nil, concurrent: false)
|
|
184
|
+
pid = phase_start(kind: kind, label: label, concurrent: concurrent)
|
|
175
185
|
begin
|
|
176
186
|
yield pid
|
|
177
187
|
ensure
|
|
@@ -88,12 +88,78 @@ module Clacky
|
|
|
88
88
|
result
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Undo one layer of accidental double-serialization, guided by the tool's
|
|
92
|
+
# parameter schema.
|
|
93
|
+
#
|
|
94
|
+
# Some LLMs (e.g. glm-5.2) emit array/object parameters as a JSON *string*
|
|
95
|
+
# (e.g. {"task":"[\"a\",\"b\"]"}) instead of a native JSON value. JSON.parse
|
|
96
|
+
# only strips the outer layer, so such values arrive here as a literal
|
|
97
|
+
# String and break downstream tools that expect an Array/Hash.
|
|
98
|
+
#
|
|
99
|
+
# To stay safe we rely on the declared schema type instead of a blind
|
|
100
|
+
# "looks like JSON" heuristic:
|
|
101
|
+
# - a parameter declared `type: "string"` is NEVER parsed, so a value
|
|
102
|
+
# that merely happens to be valid JSON text (file content, a code
|
|
103
|
+
# snippet, a JSON blob to be written) is preserved verbatim;
|
|
104
|
+
# - a parameter declared `type: "array"`/`"object"` that still arrives
|
|
105
|
+
# as a String is unwrapped exactly once;
|
|
106
|
+
# - additionally, when a concrete type IS declared, the parsed result
|
|
107
|
+
# must match it (an array-typed param that receives a JSON object
|
|
108
|
+
# string is left untouched rather than silently coerced to a Hash);
|
|
109
|
+
# - a parameter with no declared `type` (e.g. flexible params such as
|
|
110
|
+
# todo_manager's task/id, which may be a scalar or a list) falls back
|
|
111
|
+
# to a conservative heuristic: unwrap only if the stripped value
|
|
112
|
+
# starts with "["/"{" and parses cleanly.
|
|
113
|
+
# Strings that fail JSON.parse are always left untouched.
|
|
114
|
+
def self.undouble_serialize_args(args, properties = {})
|
|
115
|
+
args.to_h do |key, value|
|
|
116
|
+
expected = schema_type(properties, key)
|
|
117
|
+
# Explicit string params: never unwrap (protects content / code / JSON text).
|
|
118
|
+
next [key, value] if expected == "string"
|
|
119
|
+
# Scalar-typed params (integer/number/boolean): no JSON shape to recover.
|
|
120
|
+
next [key, value] if expected && expected != "array" && expected != "object"
|
|
121
|
+
# For array/object/untyped params, only unwrap a String that looks like JSON.
|
|
122
|
+
next [key, value] unless value.is_a?(String)
|
|
123
|
+
stripped = value.strip
|
|
124
|
+
next [key, value] unless stripped.start_with?("[") || stripped.start_with?("{")
|
|
125
|
+
begin
|
|
126
|
+
# symbolize_names keeps unwrapped Hash keys consistent with the
|
|
127
|
+
# outer JSON.parse(call[:arguments], symbolize_names: true).
|
|
128
|
+
parsed = JSON.parse(value, symbolize_names: true)
|
|
129
|
+
# Type-match guard: if the schema declares a concrete type, the
|
|
130
|
+
# parsed value must match it. Prevents silently turning a Hash into
|
|
131
|
+
# an Array (or vice-versa) when an array-typed param receives a
|
|
132
|
+
# JSON object string, etc.
|
|
133
|
+
if expected == "array" && !parsed.is_a?(Array)
|
|
134
|
+
next [key, value]
|
|
135
|
+
elsif expected == "object" && !parsed.is_a?(Hash)
|
|
136
|
+
next [key, value]
|
|
137
|
+
end
|
|
138
|
+
[key, parsed]
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
[key, value]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Look up a parameter's declared JSON-Schema type, tolerating string/symbol
|
|
146
|
+
# keys in both +properties+ and the per-param spec hash.
|
|
147
|
+
def self.schema_type(properties, key)
|
|
148
|
+
spec = properties[key] || properties[key.to_s] || properties[key.to_sym]
|
|
149
|
+
return nil unless spec.is_a?(Hash)
|
|
150
|
+
spec[:type] || spec["type"]
|
|
151
|
+
end
|
|
152
|
+
|
|
91
153
|
# Validate required parameters and filter unknown parameters
|
|
92
154
|
def self.validate_required_params(call, args, tool_registry)
|
|
93
155
|
tool = tool_registry.get(call[:name])
|
|
94
156
|
required = tool.parameters&.dig(:required) || []
|
|
95
157
|
properties = tool.parameters&.dig(:properties) || {}
|
|
96
158
|
|
|
159
|
+
# Undo accidental double-serialization BEFORE filtering, using the schema
|
|
160
|
+
# so that parameters declared as "string" are never touched.
|
|
161
|
+
args = undouble_serialize_args(args, properties)
|
|
162
|
+
|
|
97
163
|
missing = required.reject { |param|
|
|
98
164
|
args.key?(param.to_sym) || args.key?(param.to_s)
|
|
99
165
|
}
|
|
@@ -161,11 +227,14 @@ module Clacky
|
|
|
161
227
|
lines << " Name: #{tool.name}"
|
|
162
228
|
lines << " Description: #{tool.description}"
|
|
163
229
|
|
|
164
|
-
|
|
230
|
+
params = tool.parameters
|
|
231
|
+
properties = params && params[:properties]
|
|
232
|
+
if properties
|
|
165
233
|
lines << " Parameters:"
|
|
166
|
-
|
|
167
|
-
required_mark =
|
|
168
|
-
|
|
234
|
+
properties.each do |param, spec|
|
|
235
|
+
required_mark = params[:required]&.include?(param.to_s) ? " (required)" : ""
|
|
236
|
+
desc = spec.is_a?(Hash) ? spec[:description] : spec.to_s
|
|
237
|
+
lines << " - #{param}#{required_mark}: #{desc}"
|
|
169
238
|
end
|
|
170
239
|
end
|
|
171
240
|
|
|
@@ -377,6 +377,25 @@ module Clacky
|
|
|
377
377
|
}
|
|
378
378
|
},
|
|
379
379
|
|
|
380
|
+
# Gemini 3.6 Flash (GA 2026-07-21). Flat pricing, 1M context, 64K max output.
|
|
381
|
+
# Source: https://deepmind.google/models/gemini/flash/
|
|
382
|
+
# Cache write billed at input rate (Vertex doesn't expose a separate
|
|
383
|
+
# cache-write charge in the OpenAI shim usage response).
|
|
384
|
+
"gemini-3.6-flash" => {
|
|
385
|
+
input: {
|
|
386
|
+
default: 1.50,
|
|
387
|
+
over_200k: 1.50
|
|
388
|
+
},
|
|
389
|
+
output: {
|
|
390
|
+
default: 7.50,
|
|
391
|
+
over_200k: 7.50
|
|
392
|
+
},
|
|
393
|
+
cache: {
|
|
394
|
+
write: 1.50,
|
|
395
|
+
read: 0.15
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
|
|
380
399
|
# OpenAI GPT-5.5 / GPT-5.4 — breakpoint at 272K input tokens
|
|
381
400
|
# Source: https://openai.com/api/pricing/ (USD / 1M tokens)
|
|
382
401
|
# Note: OpenAI's actual tiered-pricing threshold is 272K, not the
|
|
@@ -882,6 +901,8 @@ module Clacky
|
|
|
882
901
|
"gemini-3.1-pro"
|
|
883
902
|
when /^or-gemini-3-5-flash$/i, /^gemini-3\.5-flash$/i, /^gemini-3-flash(-preview)?$/i
|
|
884
903
|
"gemini-3-flash"
|
|
904
|
+
when /^or-gemini-3-6-flash$/i, /^gemini-3\.6-flash$/i
|
|
905
|
+
"gemini-3.6-flash"
|
|
885
906
|
|
|
886
907
|
# OpenAI GPT-5.x models — match various dashed/dotted/compact forms
|
|
887
908
|
# (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
|
data/lib/clacky/version.rb
CHANGED