openclacky 1.5.6 → 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 (64) 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 +26 -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/hook_manager.rb +14 -3
  15. data/lib/clacky/agent/memory_updater.rb +17 -18
  16. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  17. data/lib/clacky/agent/session_serializer.rb +136 -22
  18. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  19. data/lib/clacky/agent/skill_evolution.rb +11 -7
  20. data/lib/clacky/agent/skill_manager.rb +20 -18
  21. data/lib/clacky/agent/skill_reflector.rb +3 -6
  22. data/lib/clacky/agent.rb +111 -20
  23. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  24. data/lib/clacky/cli.rb +4 -0
  25. data/lib/clacky/client.rb +1 -1
  26. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
  27. data/lib/clacky/extension/api_extension.rb +11 -3
  28. data/lib/clacky/fanout.rb +100 -0
  29. data/lib/clacky/locales/en.rb +2 -0
  30. data/lib/clacky/locales/zh.rb +2 -0
  31. data/lib/clacky/mcp/cli_commands.rb +61 -0
  32. data/lib/clacky/mcp/client.rb +18 -2
  33. data/lib/clacky/mcp/connection_manager.rb +85 -0
  34. data/lib/clacky/mcp/http_transport.rb +25 -9
  35. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  36. data/lib/clacky/mcp/oauth/config.rb +51 -0
  37. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  38. data/lib/clacky/mcp/oauth/session.rb +88 -0
  39. data/lib/clacky/message_format/anthropic.rb +27 -13
  40. data/lib/clacky/message_history.rb +32 -1
  41. data/lib/clacky/providers.rb +11 -5
  42. data/lib/clacky/server/http_server.rb +42 -22
  43. data/lib/clacky/server/session_registry.rb +11 -15
  44. data/lib/clacky/server/web_ui_controller.rb +1 -1
  45. data/lib/clacky/session_manager.rb +13 -11
  46. data/lib/clacky/shell_hook_loader.rb +14 -8
  47. data/lib/clacky/tools/invoke_skill.rb +3 -2
  48. data/lib/clacky/tools/terminal.rb +11 -4
  49. data/lib/clacky/ui2/progress_handle.rb +27 -3
  50. data/lib/clacky/ui2/ui_controller.rb +206 -6
  51. data/lib/clacky/ui_interface.rb +13 -3
  52. data/lib/clacky/utils/arguments_parser.rb +73 -4
  53. data/lib/clacky/utils/model_pricing.rb +21 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +10 -3
  56. data/lib/clacky/web/features/new-session/view.js +19 -2
  57. data/lib/clacky/web/i18n.js +22 -8
  58. data/lib/clacky/web/index.html +7 -11
  59. data/lib/clacky/web/projects.js +26 -0
  60. data/lib/clacky/web/sessions.js +169 -119
  61. data/lib/clacky/web/settings.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +61 -34
  63. data/lib/clacky.rb +2 -0
  64. metadata +12 -1
@@ -226,6 +226,9 @@ module Clacky
226
226
  # trigger a full output repaint (visible flicker) and the visual
227
227
  # order would have business messages appearing below the spinner.
228
228
  def append_output(content)
229
+ return nil if Thread.current[:clacky_chore_phase]
230
+
231
+ content = prefix_with_phase_label(content)
229
232
  @progress_mutex.synchronize do
230
233
  top = @progress_stack.last
231
234
  if top && top.entry_id
@@ -249,6 +252,17 @@ module Clacky
249
252
  @layout.append_output(content)
250
253
  end
251
254
 
255
+ # With several phases running at once the CLI's line-oriented output
256
+ # interleaves, so each line needs its origin stamped on it. A single
257
+ # active phase reads fine unprefixed (the banners already delimit it).
258
+ private def prefix_with_phase_label(content)
259
+ label = Thread.current[:clacky_phase_label]
260
+ return content unless label
261
+
262
+ tag = "[#{label}] "
263
+ content.to_s.split("\n", -1).map { |line| line.empty? ? line : "#{tag}#{line}" }.join("\n")
264
+ end
265
+
252
266
  # Log message to output area (use instead of puts)
253
267
  # @param message [String] Message to log
254
268
  # @param level [Symbol] Log level (:debug, :info, :warning, :error)
@@ -392,6 +406,8 @@ module Clacky
392
406
  # @param path [String] File path
393
407
  # @param is_new_file [Boolean] Whether this is a new file
394
408
  def show_file_write_preview(path, is_new_file:)
409
+ return if chore_capture(:file, path)
410
+
395
411
  theme = ThemeManager.current_theme
396
412
  file_label = theme.format_symbol(:file)
397
413
  status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
@@ -402,6 +418,8 @@ module Clacky
402
418
  # Show file operation preview (Edit tool)
403
419
  # @param path [String] File path
404
420
  def show_file_edit_preview(path)
421
+ return if chore_capture(:file, path)
422
+
405
423
  theme = ThemeManager.current_theme
406
424
  file_label = theme.format_symbol(:file)
407
425
  append_output("\n#{file_label} #{path || '(unknown)'}")
@@ -410,6 +428,8 @@ module Clacky
410
428
  # Show file operation error
411
429
  # @param error_message [String] Error message
412
430
  def show_file_error(error_message)
431
+ return if chore_capture(:error, error_message)
432
+
413
433
  theme = ThemeManager.current_theme
414
434
  append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
415
435
  end
@@ -453,10 +473,69 @@ module Clacky
453
473
  filtered.strip
454
474
  end
455
475
 
476
+ # Record a noteworthy event that happened inside a chore phase. The raw
477
+ # transcript is suppressed (a line-oriented terminal has no folding),
478
+ # but the CLI still owes the user a digest — the collapsed-card
479
+ # equivalent of what the web UI lets you expand.
480
+ #
481
+ # @return [Boolean] true if a chore phase absorbed it.
482
+ private def chore_capture(kind, text)
483
+ return false unless Thread.current[:clacky_chore_phase]
484
+ text = text.to_s.strip
485
+ return true if text.empty?
486
+
487
+ @phase_mutex ||= Mutex.new
488
+ @phase_mutex.synchronize do
489
+ @chore_digest ||= { tools: Hash.new(0), files: [], errors: [] }
490
+ case kind
491
+ when :tool then @chore_digest[:tools][text] += 1
492
+ when :file then @chore_digest[:files] << text unless @chore_digest[:files].include?(text)
493
+ when :error then @chore_digest[:errors] << text
494
+ end
495
+ end
496
+ true
497
+ end
498
+
499
+ # Turn the captured digest into the handful of lines the CLI prints
500
+ # after a chore finishes. Errors are always listed in full; tools and
501
+ # files are condensed, since a memory update touching 20 files should
502
+ # not out-scroll the answer the user actually asked for.
503
+ private def chore_digest_lines
504
+ @phase_mutex ||= Mutex.new
505
+ digest = @phase_mutex.synchronize { @chore_digest }
506
+ return [] if digest.nil?
507
+
508
+ lines = []
509
+ digest[:files].first(CHORE_DIGEST_MAX_FILES).each { |f| lines << "· #{f}" }
510
+ if (extra = digest[:files].size - CHORE_DIGEST_MAX_FILES) > 0
511
+ lines << "· …#{extra} more file#{"s" if extra > 1}"
512
+ end
513
+
514
+ if digest[:tools].any?
515
+ total = digest[:tools].values.sum
516
+ ranked = digest[:tools].sort_by { |_n, c| -c }
517
+ names = ranked.first(CHORE_DIGEST_MAX_TOOLS).map do |name, count|
518
+ count > 1 ? "#{name}×#{count}" : name
519
+ end
520
+ if (extra = ranked.size - CHORE_DIGEST_MAX_TOOLS) > 0
521
+ names << "+#{extra} more"
522
+ end
523
+ lines << "· #{total} tool call#{"s" if total > 1}: #{names.join(", ")}"
524
+ end
525
+
526
+ digest[:errors].first(CHORE_DIGEST_MAX_ERRORS).each { |e| lines << "! #{e}" }
527
+ if (extra = digest[:errors].size - CHORE_DIGEST_MAX_ERRORS) > 0
528
+ lines << "! …#{extra} more error#{"s" if extra > 1}"
529
+ end
530
+ lines
531
+ end
532
+
456
533
  # Show tool call
457
534
  # @param name [String] Tool name
458
535
  # @param args [String, Hash] Tool arguments (JSON string or Hash)
459
536
  def show_tool_call(name, args)
537
+ return if chore_capture(:tool, name)
538
+
460
539
  # Reset stdout buffer on each new tool call so previous command output
461
540
  # doesn't bleed into the next one, and so the buffer is ready before
462
541
  # on_output starts firing (which can happen before show_progress is called).
@@ -506,6 +585,8 @@ module Clacky
506
585
  # @param error [String, Exception] Error message or exception
507
586
  def show_tool_error(error)
508
587
  error_msg = error.is_a?(Exception) ? error.message : error.to_s
588
+ return if chore_capture(:error, error_msg)
589
+
509
590
  output = @renderer.render_tool_error(error: error_msg)
510
591
  append_output(output)
511
592
  end
@@ -655,6 +736,22 @@ module Clacky
655
736
 
656
737
  # Called by ProgressHandle#start.
657
738
  def register_progress(handle)
739
+ # Inside a chore phase only the phase's own indicator occupies a line.
740
+ # Nested handles (the subagent's LLM calls and tool runs) get no entry
741
+ # of their own — otherwise each one commits a permanent final frame on
742
+ # finish, since unregister_progress writes straight into the buffer and
743
+ # never passes through append_output's chore gate. Their activity is
744
+ # instead folded into the chore line by +fold_into_chore_line+.
745
+ if Thread.current[:clacky_chore_phase] && @chore_progress
746
+ @progress_mutex.synchronize do
747
+ @chore_nested ||= []
748
+ @chore_nested << handle
749
+ @chore_steps = @chore_steps.to_i + 1 if handle.style == :primary
750
+ fold_into_chore_line(handle)
751
+ end
752
+ return nil
753
+ end
754
+
658
755
  @progress_mutex.synchronize do
659
756
  prev_top = @progress_stack.last
660
757
  if prev_top
@@ -675,6 +772,17 @@ module Clacky
675
772
  # Called by ProgressHandle#finish.
676
773
  def unregister_progress(handle, final_frame:)
677
774
  @progress_mutex.synchronize do
775
+ if @chore_nested&.delete(handle)
776
+ # Folded handles own no entry; hand the chore line back to
777
+ # whatever is still running underneath (or the bare label).
778
+ fold_into_chore_line(@chore_nested.last)
779
+ next
780
+ end
781
+
782
+ # A handle that was never stacked owns nothing here: restoring
783
+ # "the new top" would duplicate what is legitimately on screen.
784
+ next unless @progress_stack.include?(handle)
785
+
678
786
  if handle.entry_id
679
787
  if final_frame && !final_frame.to_s.strip.empty?
680
788
  update_entry(handle.entry_id, @renderer.render_progress(final_frame))
@@ -705,6 +813,19 @@ module Clacky
705
813
  # +register_progress+).
706
814
  def render_frame(handle, frame)
707
815
  @progress_mutex.synchronize do
816
+ if @chore_nested&.include?(handle)
817
+ fold_into_chore_line(handle)
818
+ return
819
+ end
820
+
821
+ # The chore's own ticker must render the folded view too, otherwise
822
+ # every tick repaints the bare label and the line flickers between
823
+ # "<label>…" and "<label> · <activity>…".
824
+ if @chore_progress && @chore_progress == handle && @chore_nested&.any?
825
+ fold_into_chore_line(@chore_nested.last)
826
+ return
827
+ end
828
+
708
829
  return unless @progress_stack.last == handle
709
830
  return unless handle.entry_id
710
831
 
@@ -726,6 +847,27 @@ module Clacky
726
847
  end
727
848
  end
728
849
 
850
+ # Repaint the chore phase's single line to reflect the innermost nested
851
+ # activity: "<label> · <what it's doing> (Ns · step K)". Keeps the user
852
+ # informed without letting the chore's subagent spam one permanent line
853
+ # per LLM call. Must be called with @progress_mutex held.
854
+ private def fold_into_chore_line(inner)
855
+ chore = @chore_progress
856
+ return unless chore&.entry_id
857
+
858
+ parts = [chore.message]
859
+ parts << inner.message.to_s.strip if inner && !inner.message.to_s.strip.empty?
860
+
861
+ suffix = []
862
+ elapsed = chore.start_time ? (Time.now - chore.start_time).to_i : 0
863
+ suffix << "#{elapsed}s" if elapsed > 0
864
+ suffix << "step #{@chore_steps}" if @chore_steps.to_i > 0
865
+
866
+ line = parts.join(" · ")
867
+ line = "#{line}… (#{suffix.join(" · ")})" unless suffix.empty?
868
+ update_entry(chore.entry_id, @renderer.render_progress(line))
869
+ end
870
+
729
871
  # Render the very first frame of +handle+ (used when registering or
730
872
  # restoring). Mirrors +render_frame+'s formatting minus the implicit
731
873
  # elapsed-time tick (handle hasn't ticked yet).
@@ -888,28 +1030,86 @@ module Clacky
888
1030
  append_output(output)
889
1031
  end
890
1032
 
891
- def phase_start(kind:, label:)
1033
+ # Phases the user never asked for: post-task chores that run after the
1034
+ # answer is already on screen. The web UI folds them into a card; a
1035
+ # line-oriented terminal has no folding, so their transcript is
1036
+ # swallowed and only a one-line progress indicator remains.
1037
+ CHORE_PHASE_KINDS = %w[memory_update skill_evolution].freeze
1038
+
1039
+ # Caps for the post-chore digest. A chore is background work the user
1040
+ # never asked for, so its recap must stay far shorter than the answer.
1041
+ CHORE_DIGEST_MAX_FILES = 5
1042
+ CHORE_DIGEST_MAX_TOOLS = 4
1043
+ CHORE_DIGEST_MAX_ERRORS = 3
1044
+
1045
+ def phase_start(kind:, label:, concurrent: false)
892
1046
  phase_id = SecureRandom.uuid
893
- @active_phases ||= {}
894
- @active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now }
1047
+ chore = CHORE_PHASE_KINDS.include?(kind.to_s) && !verbose_phases?
1048
+ @phase_mutex ||= Mutex.new
1049
+ @phase_mutex.synchronize do
1050
+ @active_phases ||= {}
1051
+ @active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now, chore: chore }
1052
+ end
895
1053
  Thread.current[:clacky_phase_id] = phase_id
896
1054
 
1055
+ if chore
1056
+ # Start the indicator before arming the gate: this handle is the
1057
+ # one thing that stays visible during the chore.
1058
+ @chore_progress = start_progress(message: label, style: :quiet)
1059
+ Thread.current[:clacky_chore_phase] = phase_id
1060
+ return phase_id
1061
+ end
1062
+
897
1063
  banner = "──────── ▼ #{label} ────────"
898
1064
  append_output(@renderer.render_system_message(banner, prefix_newline: true))
1065
+ # Only concurrent phases interleave, so only they need per-line tagging.
1066
+ Thread.current[:clacky_phase_label] = concurrent ? label : nil
899
1067
  phase_id
900
1068
  end
901
1069
 
902
1070
  def phase_end(phase_id, summary: nil)
903
- Thread.current[:clacky_phase_id] = nil
904
- return unless @active_phases&.key?(phase_id)
1071
+ Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
1072
+ Thread.current[:clacky_phase_label] = nil
1073
+ @phase_mutex ||= Mutex.new
1074
+ info = @phase_mutex.synchronize { @active_phases&.delete(phase_id) }
1075
+ return unless info
905
1076
 
906
- info = @active_phases.delete(phase_id)
907
1077
  label = info[:label]
1078
+
1079
+ if info[:chore]
1080
+ Thread.current[:clacky_chore_phase] = nil
1081
+ handle = @chore_progress
1082
+ @chore_progress = nil
1083
+ @chore_nested = nil
1084
+ @chore_steps = 0
1085
+ handle&.discard
1086
+
1087
+ digest = chore_digest_lines
1088
+ @phase_mutex.synchronize { @chore_digest = nil }
1089
+
1090
+ # No summary and nothing captured means nothing worth reporting
1091
+ # happened (the common "no memory updates needed" path) — leave no
1092
+ # trace at all.
1093
+ has_summary = summary && !summary.to_s.strip.empty?
1094
+ return if !has_summary && digest.empty?
1095
+
1096
+ elapsed = (Time.now - info[:started_at]).round
1097
+ head = has_summary ? "#{label} — #{summary.to_s.strip} (#{elapsed}s)" : "#{label} (#{elapsed}s)"
1098
+ append_output(@renderer.render_system_message(head, prefix_newline: false))
1099
+ theme = ThemeManager.current_theme
1100
+ digest.each { |line| append_output(theme.format_text(" #{line}", :thinking)) }
1101
+ return
1102
+ end
1103
+
908
1104
  tail = summary && !summary.to_s.strip.empty? ? " — #{summary.to_s.strip}" : ""
909
1105
  banner = "──────── ▲ #{label} done#{tail} ────────"
910
1106
  append_output(@renderer.render_system_message(banner, prefix_newline: false))
911
1107
  end
912
1108
 
1109
+ private def verbose_phases?
1110
+ @config.respond_to?(:verbose) && @config.verbose
1111
+ end
1112
+
913
1113
  # Set workspace status to idle (called when agent stops working)
914
1114
  def set_idle_status
915
1115
  # Safety net: close any legacy progress slots that were opened via
@@ -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
- if tool.parameters[:properties]
230
+ params = tool.parameters
231
+ properties = params && params[:properties]
232
+ if properties
165
233
  lines << " Parameters:"
166
- tool.parameters[:properties].each do |param, spec|
167
- required_mark = tool.parameters[:required]&.include?(param.to_s) ? " (required)" : ""
168
- lines << " - #{param}#{required_mark}: #{spec[:description]}"
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")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.6"
4
+ VERSION = "1.5.7"
5
5
  end
@@ -1021,6 +1021,12 @@ body {
1021
1021
  color: #10b981;
1022
1022
  border-color: transparent;
1023
1023
  }
1024
+ /* Source: ext — violet tint (created by an installed extension). */
1025
+ .session-badge--ext {
1026
+ background: color-mix(in srgb, #8b5cf6 10%, transparent);
1027
+ color: #8b5cf6;
1028
+ border-color: transparent;
1029
+ }
1024
1030
  /* Source: setup — muted neutral (rarely visible in normal use). */
1025
1031
  .session-badge--setup {
1026
1032
  background: var(--color-bg-tertiary);
@@ -5555,7 +5561,8 @@ body {
5555
5561
  gap: 0.5rem;
5556
5562
  align-items: center;
5557
5563
  }
5558
- .settings-network-url-row .field-input { flex: 1; }
5564
+ .settings-network-url-row .field-input,
5565
+ .settings-network-url-row .field-select { flex: 1; }
5559
5566
  .settings-loading, .settings-empty, .settings-error {
5560
5567
  color: var(--color-text-secondary);
5561
5568
  font-size: 0.8125rem;
@@ -5719,7 +5726,7 @@ body {
5719
5726
  width: 100%;
5720
5727
  cursor: pointer;
5721
5728
  appearance: none;
5722
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
5729
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
5723
5730
  background-repeat: no-repeat;
5724
5731
  background-position: right 10px center;
5725
5732
  padding-right: 1.75rem;
@@ -6889,7 +6896,7 @@ body {
6889
6896
  .modal-select {
6890
6897
  cursor: pointer;
6891
6898
  appearance: none;
6892
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
6899
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
6893
6900
  background-repeat: no-repeat;
6894
6901
  background-position: right 12px center;
6895
6902
  padding-right: 2rem;
@@ -222,6 +222,18 @@ const NewSessionView = (() => {
222
222
 
223
223
  async function _prefillDefaultDir() {
224
224
  if (NewSessionStore.state.advanced.workingDir) return; // already set
225
+ // When a project is selected (possibly restored from localStorage by
226
+ // loadAgents), prefer its working directory over the global default so
227
+ // the new session lands in the project's folder — same behavior as
228
+ // picking the project explicitly from the context bar popover.
229
+ const project = typeof Projects !== "undefined" && Projects.find
230
+ ? Projects.find(NewSessionStore.state.advanced.projectId)
231
+ : null;
232
+ if (project && project.working_dir) {
233
+ NewSessionStore.updateAdvanced({ workingDir: project.working_dir });
234
+ _renderContextBar();
235
+ return;
236
+ }
225
237
  const home = await NewSessionStore.loadDefaultDirectory();
226
238
  if (home) {
227
239
  NewSessionStore.updateAdvanced({ workingDir: home });
@@ -857,14 +869,19 @@ const NewSessionView = (() => {
857
869
  const sendBtn = $("new-session-send");
858
870
  if (sendBtn) sendBtn.addEventListener("click", _submit);
859
871
 
860
- // Context bar: load models + default dir eagerly, then render chips
872
+ // Context bar: load models eagerly, then render chips. The working-dir
873
+ // prefill happens in onPanelShow() instead, so it runs after loadAgents()
874
+ // restores the remembered project from localStorage.
861
875
  _populateModels().then(() => _renderContextBar());
862
- _prefillDefaultDir().then(() => _renderContextBar());
863
876
  }
864
877
 
865
878
  async function onPanelShow() {
866
879
  _bindOnce();
867
880
  await NewSessionStore.loadAgents();
881
+ // Must run after loadAgents(): that call restores the remembered
882
+ // projectId from localStorage, which decides whether the working dir is
883
+ // prefilled from the project or from the global default.
884
+ await _prefillDefaultDir();
868
885
  _renderContextBar();
869
886
  _updateSendButton();
870
887
  const input = $("new-session-input");
@@ -13,6 +13,9 @@ const I18n = (() => {
13
13
  // ── Translation dictionary ─────────────────────────────────────────────────
14
14
  const TRANSLATIONS = {
15
15
  en: {
16
+ // ── Phases ──
17
+ "phase.no_changes": "no changes needed",
18
+
16
19
  // ── Sidebar ──
17
20
  "sidebar.chat": "Chat",
18
21
  "sidebar.projects": "Projects",
@@ -52,9 +55,11 @@ const I18n = (() => {
52
55
  "projects.menu.rename": "Rename",
53
56
  "projects.menu.edit": "Edit",
54
57
  "projects.menu.delete": "Delete",
58
+ "projects.menu.deleteSessions": "Delete Sessions",
55
59
  "projects.edit.title": "Edit Project",
56
60
  "projects.edit.submit": "Save",
57
61
  "projects.delete.confirm": "Delete project \"{{name}}\"? All sessions in this project will also be deleted.",
62
+ "projects.deleteSessions.confirm": "Delete all sessions in project \"{{name}}\"? The project itself will be kept.",
58
63
  "projects.modal.name": "Project Name",
59
64
 
60
65
  // ── Welcome screen ──
@@ -210,10 +215,12 @@ const I18n = (() => {
210
215
  "sib.reasoning.max": "Max",
211
216
  "sessions.thinking": "Thinking…",
212
217
  "sessions.default_name": "Session {{n}}",
213
- "sessions.cronGroup": "Scheduled Tasks",
214
- "sessions.cronGroupMeta": "{{n}} sessions",
215
- "sessions.cronLoading": "Loading scheduled tasks...",
218
+ "sessions.group.cron": "Scheduled Tasks",
219
+ "sessions.group.ext": "Extensions",
220
+ "sessions.groupMeta": "{{n}} sessions",
221
+ "sessions.groupLoading": "Loading sessions...",
216
222
  "sessions.badge.cron": "Auto",
223
+ "sessions.badge.ext": "Ext",
217
224
  "sessions.badge.channel": "Channel",
218
225
  "sessions.badge.coding": "Coding",
219
226
  "sessions.badge.setup": "Setup",
@@ -766,7 +773,7 @@ const I18n = (() => {
766
773
  "settings.network.cleared": "Cleared — direct connection",
767
774
  "settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
768
775
  "settings.search.title": "Web Search",
769
- "settings.search.desc": "By default web_search scrapes DuckDuckGo and Bing, which needs no key but returns lower-quality results. Select a search provider to use an API instead — it falls back to the built-in engines if the provider fails.",
776
+ "settings.search.desc": "Free built-in search (DuckDuckGo / Bing) works out of the box. Connect an API provider below for higher-quality results — falls back to built-in if unavailable.",
770
777
  "settings.search.provider": "Provider",
771
778
  "settings.search.builtin": "Built-in (DuckDuckGo / Bing)",
772
779
  "settings.search.key": "API Key",
@@ -1086,6 +1093,9 @@ const I18n = (() => {
1086
1093
  "billing.disclaimer": "Costs are estimated based on token usage and may differ from your provider's actual bill.",
1087
1094
  },
1088
1095
  zh: {
1096
+ // ── Phases ──
1097
+ "phase.no_changes": "无需改动",
1098
+
1089
1099
  // ── Sidebar ──
1090
1100
  "sidebar.chat": "会话",
1091
1101
  "sidebar.projects": "项目",
@@ -1124,9 +1134,11 @@ const I18n = (() => {
1124
1134
  "projects.menu.rename": "重命名",
1125
1135
  "projects.menu.edit": "编辑",
1126
1136
  "projects.menu.delete": "删除",
1137
+ "projects.menu.deleteSessions": "删除会话",
1127
1138
  "projects.edit.title": "编辑项目",
1128
1139
  "projects.edit.submit": "保存",
1129
1140
  "projects.delete.confirm": "删除项目「{{name}}」?该项目下的所有会话也将被删除。",
1141
+ "projects.deleteSessions.confirm": "删除项目「{{name}}」下的所有会话?项目本身会被保留。",
1130
1142
  "projects.modal.name": "项目名称",
1131
1143
 
1132
1144
  // ── Welcome screen ──
@@ -1280,10 +1292,12 @@ const I18n = (() => {
1280
1292
  "sib.reasoning.max": "Max",
1281
1293
  "sessions.thinking": "思考中…",
1282
1294
  "sessions.default_name": "会话 {{n}}",
1283
- "sessions.cronGroup": "定时任务",
1284
- "sessions.cronGroupMeta": "{{n}} 个会话",
1285
- "sessions.cronLoading": "正在加载定时任务...",
1295
+ "sessions.group.cron": "定时任务",
1296
+ "sessions.group.ext": "扩展",
1297
+ "sessions.groupMeta": "{{n}} 个会话",
1298
+ "sessions.groupLoading": "正在加载会话...",
1286
1299
  "sessions.badge.cron": "定时",
1300
+ "sessions.badge.ext": "扩展",
1287
1301
  "sessions.badge.channel": "频道",
1288
1302
  "sessions.badge.coding": "Coding",
1289
1303
  "sessions.badge.setup": "配置",
@@ -1842,7 +1856,7 @@ const I18n = (() => {
1842
1856
  "settings.network.cleared": "已清除 — 直连",
1843
1857
  "settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
1844
1858
  "settings.search.title": "网页搜索",
1845
- "settings.search.desc": "默认 web_search 抓取 DuckDuckGo 和 Bing,无需密钥但结果质量较低。选择搜索服务商可改用 API 搜索,服务商失败时会自动回退到内置引擎。",
1859
+ "settings.search.desc": "内置免费搜索(DuckDuckGo / Bing)开箱即用。在下方接入 API 服务商可获得更高质量的搜索结果,不可用时自动回退到内置引擎。",
1846
1860
  "settings.search.provider": "服务商",
1847
1861
  "settings.search.builtin": "内置(DuckDuckGo / Bing)",
1848
1862
  "settings.search.key": "API 密钥",