openclacky 1.5.6 → 1.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/README.md +2 -2
  7. data/README_CN.md +1 -1
  8. data/README_JA.md +1 -1
  9. data/Rakefile +15 -0
  10. data/docs/design-emit-and-background-skills.md +253 -0
  11. data/docs/mcp-architecture.md +48 -9
  12. data/docs/mcp.example.json +9 -0
  13. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  14. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  15. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  16. data/lib/clacky/agent/cost_tracker.rb +17 -2
  17. data/lib/clacky/agent/hook_manager.rb +14 -3
  18. data/lib/clacky/agent/memory_updater.rb +17 -18
  19. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  20. data/lib/clacky/agent/session_serializer.rb +136 -22
  21. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  22. data/lib/clacky/agent/skill_evolution.rb +11 -7
  23. data/lib/clacky/agent/skill_manager.rb +20 -18
  24. data/lib/clacky/agent/skill_reflector.rb +3 -6
  25. data/lib/clacky/agent.rb +113 -23
  26. data/lib/clacky/agent_config.rb +40 -18
  27. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  28. data/lib/clacky/cli.rb +4 -1
  29. data/lib/clacky/client.rb +1 -1
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
  32. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  33. data/lib/clacky/extension/api_extension.rb +11 -3
  34. data/lib/clacky/extension/api_loader.rb +4 -11
  35. data/lib/clacky/extension/packager.rb +1 -1
  36. data/lib/clacky/extension/verifier.rb +1 -1
  37. data/lib/clacky/fanout.rb +100 -0
  38. data/lib/clacky/locales/en.rb +2 -0
  39. data/lib/clacky/locales/zh.rb +2 -0
  40. data/lib/clacky/mcp/cli_commands.rb +61 -0
  41. data/lib/clacky/mcp/client.rb +18 -2
  42. data/lib/clacky/mcp/connection_manager.rb +85 -0
  43. data/lib/clacky/mcp/http_transport.rb +25 -9
  44. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  45. data/lib/clacky/mcp/oauth/config.rb +51 -0
  46. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  47. data/lib/clacky/mcp/oauth/session.rb +88 -0
  48. data/lib/clacky/media/generator.rb +3 -3
  49. data/lib/clacky/message_format/anthropic.rb +27 -13
  50. data/lib/clacky/message_history.rb +33 -2
  51. data/lib/clacky/providers.rb +13 -6
  52. data/lib/clacky/server/http_server.rb +102 -98
  53. data/lib/clacky/server/session_registry.rb +11 -15
  54. data/lib/clacky/server/web_ui_controller.rb +1 -1
  55. data/lib/clacky/session_manager.rb +21 -12
  56. data/lib/clacky/shell_hook_loader.rb +14 -8
  57. data/lib/clacky/tools/invoke_skill.rb +3 -2
  58. data/lib/clacky/tools/terminal.rb +11 -4
  59. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  60. data/lib/clacky/ui2/components/input_area.rb +1 -0
  61. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  62. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  63. data/lib/clacky/ui2/line_editor.rb +1 -0
  64. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  65. data/lib/clacky/ui2/progress_handle.rb +27 -3
  66. data/lib/clacky/ui2/ui_controller.rb +207 -6
  67. data/lib/clacky/ui_interface.rb +13 -3
  68. data/lib/clacky/utils/arguments_parser.rb +73 -4
  69. data/lib/clacky/utils/model_pricing.rb +21 -0
  70. data/lib/clacky/version.rb +1 -1
  71. data/lib/clacky/web/app.css +144 -213
  72. data/lib/clacky/web/components/chat-navigator.js +1 -1
  73. data/lib/clacky/web/features/billing/store.js +3 -3
  74. data/lib/clacky/web/features/billing/view.js +5 -61
  75. data/lib/clacky/web/features/extensions/store.js +1 -1
  76. data/lib/clacky/web/features/extensions/view.js +28 -5
  77. data/lib/clacky/web/features/new-session/view.js +43 -2
  78. data/lib/clacky/web/i18n.js +38 -22
  79. data/lib/clacky/web/index.html +19 -24
  80. data/lib/clacky/web/projects.js +96 -279
  81. data/lib/clacky/web/sessions.js +169 -119
  82. data/lib/clacky/web/settings.js +7 -1
  83. data/lib/clacky/web/ws-dispatcher.js +61 -34
  84. data/lib/clacky.rb +2 -0
  85. metadata +12 -1
data/lib/clacky/agent.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "securerandom"
4
4
  require "json"
5
5
  require "cgi"
6
- require "tty-prompt"
7
6
  require "set"
8
7
  require_relative "null_ui_controller"
9
8
  require_relative "utils/arguments_parser"
@@ -51,7 +50,6 @@ module Clacky
51
50
  :latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
52
51
  :reasoning_effort
53
52
  attr_accessor :pinned
54
- attr_accessor :hidden
55
53
  attr_accessor :channel_info
56
54
  attr_accessor :project_id
57
55
 
@@ -82,15 +80,15 @@ module Clacky
82
80
  @source = source.to_sym # :manual | :cron | :channel
83
81
  @channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
84
82
  @tool_registry = ToolRegistry.new
85
- @hooks = HookManager.new
83
+ @hooks = HookManager.new(agent: self)
86
84
  @session_id = session_id
87
85
  @name = ""
88
86
  @pinned = false
89
- @hidden = false
90
87
  @history = MessageHistory.new
91
88
  @todos = [] # Store todos in memory
92
89
  @iterations = 0
93
90
  @total_cost = 0.0
91
+ @cost_mutex = Mutex.new
94
92
  @cache_stats = {
95
93
  cache_creation_input_tokens: 0,
96
94
  cache_read_input_tokens: 0,
@@ -110,7 +108,7 @@ module Clacky
110
108
  @ui = ui # UIController for direct UI interaction
111
109
  @debug_logs = [] # Debug logs for troubleshooting
112
110
  @pending_injections = [] # Pending inline skill injections to flush after observe()
113
- @pending_subagent_transcript = nil # Subagent trail to attach to the next tool result (observe)
111
+ @pending_subagent_transcripts = {} # tool_call_id => subagent trail, attached by observe()
114
112
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
115
113
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
116
114
  @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)
@@ -287,6 +285,27 @@ module Clacky
287
285
  @goal_manager&.active? || false
288
286
  end
289
287
 
288
+ # Emit a custom extension event to the UI.
289
+ #
290
+ # `type` must be namespaced "ext.<extension>.<event>" so custom events can
291
+ # never collide with the built-in protocol.
292
+ #
293
+ # Transient by default: progress ticks and other high-frequency chatter are
294
+ # pushed live and forgotten, so extensions cannot bloat session.json without
295
+ # opting in. Pass `persist: true` for milestone events that must reappear in
296
+ # the chat stream after a reload; those are anchored to the current message,
297
+ # survive compression via the chunk MD, and are replayed in place.
298
+ def emit_event(type, persist: false, **data)
299
+ name = type.to_s
300
+ unless name.start_with?("ext.")
301
+ raise ArgumentError, "custom event type must be namespaced as 'ext.<extension>.<event>', got #{name.inspect}"
302
+ end
303
+
304
+ @ui&.emit(name, **data)
305
+ @history.append_ext_event({ type: name, data: data }) if persist
306
+ self
307
+ end
308
+
290
309
  # Resolve the model name the goal judge should use. Prefer the provider's
291
310
  # lite model (cheap/fast for a one-line verdict); fall back to the primary
292
311
  # model when no lite is resolvable.
@@ -1139,6 +1158,13 @@ module Clacky
1139
1158
  next
1140
1159
  end
1141
1160
 
1161
+ # A hook fulfilled the call itself (e.g. an extension that runs the
1162
+ # skill on its own subagents). Its result stands in for the tool's.
1163
+ if hook_result[:action] == :handled
1164
+ results << build_success_result(call, hook_result[:result])
1165
+ next
1166
+ end
1167
+
1142
1168
  # Show preview for edit and write tools even in auto-approve mode
1143
1169
  if should_auto_execute?(call[:name], call[:arguments])
1144
1170
  # In auto-approve mode, show preview for edit and write tools
@@ -1196,10 +1222,13 @@ module Clacky
1196
1222
  args[:todos_storage] = @todos
1197
1223
  end
1198
1224
 
1199
- # Special handling for InvokeSkill: inject agent and skill_loader
1225
+ # Special handling for InvokeSkill: inject agent and skill_loader.
1226
+ # tool_call_id lets the subagent transcript be keyed to *this* call, so
1227
+ # multiple invoke_skill calls in one turn don't cross-attach trails.
1200
1228
  if call[:name] == "invoke_skill"
1201
1229
  args[:agent] = self
1202
1230
  args[:skill_loader] = @skill_loader
1231
+ args[:tool_call_id] = call[:id]
1203
1232
  end
1204
1233
 
1205
1234
  # Inject working_dir so tools don't rely on Dir.chdir global state
@@ -1351,7 +1380,7 @@ module Clacky
1351
1380
  @history.append(truncated.merge(task_id: @current_task_id))
1352
1381
  end
1353
1382
 
1354
- attach_pending_subagent_transcript(response)
1383
+ attach_pending_subagent_transcripts(response)
1355
1384
 
1356
1385
  # Append a follow-up `role:"user"` message for any image payloads that
1357
1386
  # could not be delivered inside the tool message.
@@ -1362,7 +1391,7 @@ module Clacky
1362
1391
  # plain text, inflating token counts by 20-40x. The tool result carries a
1363
1392
  # plain-text description for the LLM; the actual image is delivered here.
1364
1393
  vision_supported = @config.current_model_supports?(:vision)
1365
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1394
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1366
1395
 
1367
1396
  tool_results.each do |tr|
1368
1397
  inject = tr[:image_inject]
@@ -1396,22 +1425,24 @@ module Clacky
1396
1425
  end
1397
1426
  end
1398
1427
 
1399
- # Attach the captured subagent transcript (set by execute_skill_with_subagent)
1400
- # to the invoke_skill tool result message just appended by observe(). The
1401
- # transcript rides on the tool message as :subagent_transcript — an internal
1402
- # field stripped before the LLM call but persisted to session.json and
1403
- # replayed to the WebUI. Cleared after attaching so it fires exactly once.
1404
- private def attach_pending_subagent_transcript(response)
1405
- transcript = @pending_subagent_transcript
1406
- return unless transcript
1428
+ # Attach captured subagent transcripts (set by execute_skill_with_subagent)
1429
+ # to their own invoke_skill tool result messages just appended by observe().
1430
+ # Each transcript rides on its tool message as :subagent_transcript — an
1431
+ # internal field stripped before the LLM call but persisted to session.json
1432
+ # and replayed to the WebUI. Keyed by tool_call_id so a turn with several
1433
+ # invoke_skill calls attaches each subagent's trail to the call that spawned
1434
+ # it. Consumed entries are deleted so each fires exactly once.
1435
+ private def attach_pending_subagent_transcripts(response)
1436
+ return if @pending_subagent_transcripts.empty?
1407
1437
 
1408
- @pending_subagent_transcript = nil
1438
+ Array(response[:tool_calls]).each do |tc|
1439
+ next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
1409
1440
 
1410
- skill_call = Array(response[:tool_calls]).find { |tc| (tc[:name] || tc.dig(:function, :name)) == "invoke_skill" }
1411
- target_id = skill_call && skill_call[:id]
1412
- return unless target_id
1441
+ transcript = @pending_subagent_transcripts.delete(tc[:id])
1442
+ next unless transcript
1413
1443
 
1414
- @history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
1444
+ @history.attach_to_tool_result(tc[:id], :subagent_transcript, transcript)
1445
+ end
1415
1446
  end
1416
1447
 
1417
1448
  # Cap oversized tool result content to keep a single tool message from
@@ -1587,8 +1618,67 @@ module Clacky
1587
1618
  parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1588
1619
  result = subagent.run(task)
1589
1620
 
1590
- @total_cost += result[:total_cost_usd] || 0.0
1621
+ # A detached run stays invisible, so its cost is merged silently — the
1622
+ # sessionbar refresh would be the one thing that gives it away.
1623
+ absorb_subagent_cost(result, notify_ui: false)
1624
+
1625
+ final_assistant_text(subagent, parent_count)
1626
+ end
1627
+
1628
+ # Run several independent tasks on forked subagents at the same time.
1629
+ #
1630
+ # Each subagent is forked on the calling thread (forking deep-copies parent
1631
+ # config + history, which must not race) and only the blocking run is moved
1632
+ # onto the pool. Results keep the order of `tasks`; a task that raises or
1633
+ # overruns the budget yields a failed slot instead of aborting its siblings.
1634
+ #
1635
+ # Subagents run silently by default: they share the parent's session, so a
1636
+ # live UI would interleave several raw transcripts into one chat.
1637
+ #
1638
+ # @param tasks [Array<String>] one prompt per subagent
1639
+ # @param model [String, nil] model for every subagent (nil = current)
1640
+ # @param forbidden_tools [Array<String>] tool names blocked at runtime
1641
+ # @param max_concurrency [Integer] subagents allowed to run at once
1642
+ # @param timeout [Numeric, nil] wall-clock budget for the whole batch
1643
+ # @return [Array<Fanout::Result>] value is the subagent's final reply text
1644
+ def fan_out_subagents(tasks, model: nil, forbidden_tools: [], max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY,
1645
+ timeout: nil)
1646
+ return [] if tasks.empty?
1647
+
1648
+ jobs = tasks.each_with_index.map do |task, index|
1649
+ subagent = fork_subagent(
1650
+ model: model,
1651
+ forbidden_tools: forbidden_tools,
1652
+ system_prompt_suffix: "You are one of several subagents running in parallel on independent tasks. " \
1653
+ "Do your task and return only the requested output. Do not ask follow-up questions."
1654
+ )
1655
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1656
+ label = "Subagent #{index + 1}/#{tasks.size}"
1657
+ # Fanout workers are fresh threads, so the epoch that lets the web
1658
+ # broadcaster drop events from superseded tasks has to be carried over
1659
+ # by hand — otherwise interrupted subagents keep writing to the new task.
1660
+ epoch = Thread.current[:task_epoch]
1661
+
1662
+ lambda do
1663
+ Thread.current[:task_epoch] = epoch
1664
+ within_phase(label, kind: "fanout_subagent", concurrent: true) do
1665
+ result = subagent.run(task)
1666
+ absorb_subagent_cost(result, notify_ui: false)
1667
+ final_assistant_text(subagent, parent_count)
1668
+ end
1669
+ end
1670
+ end
1671
+
1672
+ Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(jobs)
1673
+ end
1674
+
1675
+ private def within_phase(label, kind:, concurrent:, &block)
1676
+ return yield unless @ui.respond_to?(:with_phase)
1677
+
1678
+ @ui.with_phase(kind: kind, label: label, concurrent: concurrent, &block)
1679
+ end
1591
1680
 
1681
+ private def final_assistant_text(subagent, parent_count)
1592
1682
  new_messages = subagent.history.to_a[parent_count..] || []
1593
1683
  new_messages
1594
1684
  .reverse
@@ -1868,7 +1958,7 @@ module Clacky
1868
1958
  # OCR sidecar — only consulted when the primary doesn't see images.
1869
1959
  # When the sidecar entry has "primary"=>true, the primary itself can see,
1870
1960
  # so vision_supported was already true and we never enter the OCR branch.
1871
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1961
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1872
1962
 
1873
1963
  vision_images = [] # Array of { url:, name:, size_bytes:, path: }
1874
1964
  downgraded = []
@@ -633,16 +633,10 @@ module Clacky
633
633
  }.compact
634
634
  end
635
635
 
636
- # Find model by type (default or lite or media kind or ocr sidecar)
637
- # Returns the model hash or nil if not found.
638
- # For media kinds (image/video/audio): explicit user-configured (custom)
639
- # entries win; otherwise an auto-derived virtual entry is returned
640
- # based on the default model's provider — mirroring how lite is
641
- # virtually derived via #lite_model_config_for_current.
642
- # For "ocr": same custom→auto→nil pattern. Auto path first checks
643
- # whether the default model itself supports vision (zero-overhead path,
644
- # no sidecar needed); if not, derives from the provider's
645
- # default_ocr_model.
636
+ # Raw accessor - returns the stored model entry without mode-aware filtering.
637
+ # For media/ocr: does NOT check mode field, only legacy disabled flag.
638
+ # Mode-aware callers should use #effective_media_entry / #effective_ocr_entry.
639
+ # HTTP server uses this to read raw entries for saved_custom / api_key masking.
646
640
  def find_model_by_type(type)
647
641
  kind = type.to_s
648
642
  if Clacky::Providers::MEDIA_KINDS.include?(kind)
@@ -701,6 +695,38 @@ module Clacky
701
695
  @models.reject! { |m| m["auto_injected"] && Clacky::Providers::MEDIA_KINDS.include?(m["type"].to_s) }
702
696
  end
703
697
 
698
+ private def sidecar_off?(entry)
699
+ entry && (entry["disabled"] || entry["mode"] == "off")
700
+ end
701
+
702
+ private def sidecar_custom?(entry)
703
+ entry &&
704
+ entry["base_url"].to_s.strip != "" &&
705
+ entry["api_key"].to_s.strip != "" &&
706
+ entry["mode"] != "auto"
707
+ end
708
+
709
+ def effective_media_entry(kind)
710
+ kind = kind.to_s
711
+ raw_entry = @models.find { |m| m["type"] == kind }
712
+ return nil if sidecar_off?(raw_entry)
713
+
714
+ is_custom = sidecar_custom?(raw_entry)
715
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
716
+
717
+ is_custom ? raw_entry : derive_media_model(kind, model_override: override_model)
718
+ end
719
+
720
+ def effective_ocr_entry
721
+ raw_entry = @models.find { |m| m["type"] == "ocr" }
722
+ return nil if sidecar_off?(raw_entry)
723
+
724
+ is_custom = sidecar_custom?(raw_entry)
725
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
726
+
727
+ is_custom ? raw_entry : derive_ocr_model(model_override: override_model)
728
+ end
729
+
704
730
  # Derive an OCR sidecar model entry from the default model's provider.
705
731
  # Resolution order:
706
732
  # 1. If the default model itself supports vision → return the default
@@ -763,7 +789,7 @@ module Clacky
763
789
  kind = kind.to_s
764
790
  raw_entry = @models.find { |m| m["type"] == kind }
765
791
 
766
- if raw_entry && raw_entry["disabled"]
792
+ if sidecar_off?(raw_entry)
767
793
  default = find_model_by_type("default")
768
794
  default_provider = default && Clacky::Providers.resolve_provider(
769
795
  base_url: default["base_url"], api_key: default["api_key"]
@@ -782,9 +808,7 @@ module Clacky
782
808
  }
783
809
  end
784
810
 
785
- is_custom = raw_entry &&
786
- raw_entry["base_url"].to_s.strip != "" &&
787
- raw_entry["api_key"].to_s.strip != ""
811
+ is_custom = sidecar_custom?(raw_entry)
788
812
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
789
813
 
790
814
  entry = if is_custom
@@ -842,7 +866,7 @@ module Clacky
842
866
  )
843
867
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
844
868
 
845
- if raw_entry && raw_entry["disabled"]
869
+ if sidecar_off?(raw_entry)
846
870
  # A disabled OCR sidecar only means "no separate vision model"; it must
847
871
  # not override the fact that the chat model may handle images itself.
848
872
  anchor = current_model || default
@@ -872,9 +896,7 @@ module Clacky
872
896
  }
873
897
  end
874
898
 
875
- is_custom = raw_entry &&
876
- raw_entry["base_url"].to_s.strip != "" &&
877
- raw_entry["api_key"].to_s.strip != ""
899
+ is_custom = sidecar_custom?(raw_entry)
878
900
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
879
901
 
880
902
  entry = if is_custom
@@ -121,7 +121,12 @@ module Clacky
121
121
 
122
122
  private def emit_usage_progress
123
123
  return unless @on_chunk
124
- input = @usage["input_tokens"].to_i + @usage["cache_read_input_tokens"].to_i
124
+ raw_input = @usage["input_tokens"].to_i
125
+ cache_read = @usage["cache_read_input_tokens"].to_i
126
+ cache_creation = @usage["cache_creation_input_tokens"].to_i
127
+ # Mirrors MessageFormat::Anthropic.parse_response so the live readout and
128
+ # the final billed numbers agree.
129
+ input = MessageFormat::Anthropic.normalise_prompt_tokens(raw_input, cache_read, cache_creation)
125
130
  output = @usage["output_tokens"].to_i
126
131
  return if input == @last_input_tokens && output == @last_output_tokens
127
132
  @last_input_tokens = input
data/lib/clacky/cli.rb CHANGED
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "tty-prompt"
5
4
  require "fileutils"
6
5
  require_relative "ui2"
7
6
  require_relative "json_ui_controller"
8
7
  require_relative "plain_ui_controller"
9
8
  require_relative "brand_config"
10
9
  require_relative "extension/cli_commands"
10
+ require_relative "mcp/cli_commands"
11
11
 
12
12
  module Clacky
13
13
  class CLI < Thor
@@ -1217,6 +1217,9 @@ module Clacky
1217
1217
  desc "ext SUBCOMMAND", "Manage extension containers (new, verify, list, pack, install, publish, search)"
1218
1218
  subcommand "ext", Clacky::CliExtensionCommands
1219
1219
 
1220
+ desc "mcp SUBCOMMAND", "Manage MCP server connections and OAuth authorization"
1221
+ subcommand "mcp", Clacky::Mcp::CliCommands
1222
+
1220
1223
  desc "billing", "Show billing summary and usage statistics"
1221
1224
  long_desc <<-LONGDESC
1222
1225
  Display billing summary with token usage and cost breakdown.
data/lib/clacky/client.rb CHANGED
@@ -415,7 +415,7 @@ module Clacky
415
415
  if defined?(Clacky::Logger)
416
416
  Clacky::Logger.warn("[parse_simple_openai_response] no content. status=#{response.status} body=#{snippet}")
417
417
  end
418
- raise Clacky::Error,
418
+ raise RetryableError,
419
419
  "Upstream OpenAI-compatible response missing choices[0].message.content. " \
420
420
  "Body snippet: #{snippet}"
421
421
  end
@@ -148,6 +148,7 @@
148
148
  "extlist.unpublish.confirm": "Unpublish {{id}} from the marketplace?",
149
149
  "extlist.publishing": "Publishing…",
150
150
  "extlist.needLicense": "Publishing requires an activated user license.",
151
+ "extlist.brandLocked": "Publishing to the marketplace requires a creator license.",
151
152
  "extlist.newExt.label": "Create a new extension",
152
153
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
153
154
  "extlist.newExt.btn": "Create New Extension",
@@ -338,6 +339,7 @@
338
339
  "extlist.unpublish.confirm": "确定要从市场下架 {{id}} 吗?",
339
340
  "extlist.publishing": "发布中…",
340
341
  "extlist.needLicense": "发布需要已激活的用户授权。",
342
+ "extlist.brandLocked": "发布到市场需要创作者权限。",
341
343
  "extlist.newExt.label": "创建新扩展",
342
344
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
343
345
  "extlist.newExt.btn": "创建新扩展",
@@ -1406,11 +1408,16 @@
1406
1408
  ));
1407
1409
 
1408
1410
  const bar = el("div", { class: "studio-actions" });
1409
- bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
1411
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1412
+ const pubBtn = el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish });
1413
+ pubBtn.disabled = brandNonAdmin;
1414
+ if (brandNonAdmin) pubBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1415
+ bar.appendChild(pubBtn);
1410
1416
  if (typeof Brand !== "undefined" && Brand.userLicensed) {
1411
1417
  bar.appendChild(el("button", { class: "studio-btn studio-publish-brand-btn", text: t("btn.publishBrand"), onclick: doPublishBrand }));
1412
1418
  }
1413
- bar.appendChild(el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack }));
1419
+ const packBtn = el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack });
1420
+ bar.appendChild(packBtn);
1414
1421
  container.appendChild(bar);
1415
1422
 
1416
1423
  container.appendChild(el("div", { class: "studio-feedback" }));
@@ -1548,9 +1555,11 @@
1548
1555
  card.appendChild(meta);
1549
1556
 
1550
1557
  const actions = el("div", { class: "studio-actions" });
1558
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1551
1559
  const pub = el("button", { class: "studio-btn studio-btn-primary", text: published ? t("extlist.btn.update") : t("extlist.btn.publish") });
1552
- pub.disabled = !!ext.error_count;
1553
- pub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1560
+ pub.disabled = !!ext.error_count || brandNonAdmin;
1561
+ const pubTip = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : (brandNonAdmin ? t("extlist.brandLocked") : "");
1562
+ if (pubTip) pub.setAttribute("data-tooltip", pubTip);
1554
1563
  pub.addEventListener("click", () => doPublish(ext, cloudEntry ? cloudEntry.version : null));
1555
1564
  actions.appendChild(pub);
1556
1565
 
@@ -1558,7 +1567,7 @@
1558
1567
  const brandEntry = brandCloud.find((c) => c.id === ext.id);
1559
1568
  const brandPub = el("button", { class: "studio-btn", text: t("extlist.btn.publishBrand") });
1560
1569
  brandPub.disabled = !!ext.error_count;
1561
- brandPub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1570
+ if (ext.error_count) brandPub.setAttribute("data-tooltip", t("extlist.verify.errors", { n: ext.error_count }));
1562
1571
  brandPub.addEventListener("click", () => doPublish(ext, brandEntry ? brandEntry.version : null, "self"));
1563
1572
  actions.appendChild(brandPub);
1564
1573
  }
@@ -1635,11 +1644,15 @@
1635
1644
 
1636
1645
  if (headerActions) {
1637
1646
  headerActions.innerHTML = "";
1638
- headerActions.appendChild(el("button", {
1647
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1648
+ const newBtn = el("button", {
1639
1649
  class: "studio-btn studio-btn-primary",
1640
1650
  text: t("extlist.newExt.btn"),
1641
1651
  onclick: () => createExtension(t("extlist.newExt.seed")),
1642
- }));
1652
+ });
1653
+ newBtn.disabled = brandNonAdmin;
1654
+ if (brandNonAdmin) newBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1655
+ headerActions.appendChild(newBtn);
1643
1656
  }
1644
1657
 
1645
1658
  container.appendChild(section("extlist.section.cloud", "extlist.section.cloudHint", cloud, cloudCard, "extlist.cloud.empty"));
@@ -1711,7 +1724,7 @@
1711
1724
  if (skill.local_present && skill.has_local_changes) {
1712
1725
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.update") });
1713
1726
  btn.disabled = locked;
1714
- btn.title = locked ? t("skills.locked") : "";
1727
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1715
1728
  btn.addEventListener("click", () => doPublish(skill.name, btn, true));
1716
1729
  actions.appendChild(btn);
1717
1730
  } else if (skill.local_present) {
@@ -1738,7 +1751,7 @@
1738
1751
  const actions = el("div", { class: "studio-actions" });
1739
1752
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.publish") });
1740
1753
  btn.disabled = locked;
1741
- btn.title = locked ? t("skills.locked") : "";
1754
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1742
1755
  btn.addEventListener("click", () => doPublish(skill.name, btn, false));
1743
1756
  actions.appendChild(btn);
1744
1757
  card.appendChild(actions);
@@ -1798,7 +1811,7 @@
1798
1811
  onclick: () => createSkill(null),
1799
1812
  });
1800
1813
  newBtn.disabled = locked;
1801
- newBtn.title = locked ? t("skills.locked") : "";
1814
+ if (locked) newBtn.setAttribute("data-tooltip", t("skills.locked"));
1802
1815
  headerActions.appendChild(newBtn);
1803
1816
  }
1804
1817
 
@@ -206,16 +206,21 @@ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filena
206
206
  - Drive sessions from the backend: `create_session(prompt:, profile:, …)`,
207
207
  `submit_task(session_id, prompt)`, `dispatch_to_session(session_id, prompt)` (runs a
208
208
  side task on a fork and returns its reply without touching the conversation).
209
- - `create_session(hidden: true)` creates a session the openclacky session list hides
210
- and the 200-session cleanup skips - use it for dedicated extension sessions you
211
- manage yourself. Still reachable by `session_id` via `submit_task` /
212
- `dispatch_to_session` / `registry.with_session`; forking resets `hidden` to false.
213
209
  - **Projects**: `project_manager.all` lists projects, `find(id)` returns one (or
214
210
  `nil`), `create(name:, working_dir:, …)` / `update(id, …)` / `delete(id)` mutate.
215
211
  Pass `project_id:` to `create_session` to bind a session to a project - its
216
212
  `working_dir` is inherited (unless overridden) and `agent.project_id` is persisted.
217
213
  A panel can also `fetch("/api/projects")` directly (same-origin, no-auth; see
218
214
  [Host API](/docs/extend-host-api)).
215
+ - **Session source grouping** (⚠️ opt-in only): `create_session` accepts `source: "manual"`
216
+ (default) or `source: "ext"`; anything else is rejected with 400. Use `"ext"` **only**
217
+ when the extension creates sessions the user did not individually ask for (bookkeeping,
218
+ background workers, one per webhook) and would otherwise flood the list — they collapse
219
+ under a single "Extensions" sidebar entry with their own 200-session cleanup pool.
220
+ **Default to `"manual"`**: a session the user asked for is one they should be able to
221
+ find, and folded sessions are easy to overlook while the separate pool silently consumes
222
+ storage until evicted. Sessions bound to a project always show in that project's area
223
+ regardless of `source`, and count toward the regular pool rather than the ext one.
219
224
  - Public (no-auth) endpoints: call `public_endpoint("/path")` in the class **and** set
220
225
  `public: true` at ext.yml top level — both are required.
221
226
 
@@ -230,7 +235,10 @@ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filena
230
235
  `event` values (exactly these): `before_tool_use after_tool_use on_tool_error on_start
231
236
  on_complete on_iteration session_rollback`. A `before_tool_use` hook returning
232
237
  `{ action: :deny, reason: "…" }` **blocks** the tool call — this is how you audit or
233
- gate dangerous commands.
238
+ gate dangerous commands. Every event passes the **agent as its last block arg**
239
+ (`|call, result, agent|` for `after_tool_use`); call `agent.emit_event("ext.<id>.<event>",
240
+ persist: true, **data)` to push structured events to the frontend. `persist: false`
241
+ (default) vanishes on refresh — emit a `persist: true` terminal event at the end.
234
242
 
235
243
  ## Scaffold
236
244
 
@@ -415,8 +423,9 @@ smallest change, re-run verify, repeat until clean — fix ONE issue at a time.
415
423
  - **`schema.unknown_contributes`** → a top-level key under `contributes:` is misspelled.
416
424
  **Do:** fix the spelling to one of `panels api skills agents channels patches hooks`.
417
425
  - **`schema.unknown_key`** → an unknown **top-level** key in `ext.yml`. **Do:** fix the
418
- spelling. Allowed top-level keys: `id name title description version origin author
419
- homepage license public license_required keywords contributes`.
426
+ spelling. Allowed top-level keys: `id name name_zh display_name display_name_zh title
427
+ description description_zh emoji version origin author homepage license public
428
+ license_required keywords contributes`.
420
429
  - **`schema.unknown_field`** → a unit has a field not allowed for its type. **Do:** delete
421
430
  or rename that field. Allowed fields per type (this is the authoritative list — do not
422
431
  invent others):
@@ -15,4 +15,4 @@ contributes:
15
15
  order: 1
16
16
  prompt: agents/general/system_prompt.md
17
17
  avatar: agents/general/avatar.png
18
- panels: [meeting/meeting]
18
+ panels: []
@@ -27,6 +27,10 @@ module Clacky
27
27
  MAX_TIMEOUT = 600
28
28
  DEFAULT_TIMEOUT = 10
29
29
 
30
+ # "ext" groups the session under the sidebar's folded extension entry and
31
+ # gives it a dedicated cleanup pool; extensions cannot claim other sources.
32
+ ALLOWED_SESSION_SOURCES = %w[manual ext].freeze
33
+
30
34
  Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
31
35
 
32
36
  class Halt < StandardError
@@ -270,9 +274,14 @@ module Clacky
270
274
  # (unless an explicit working_dir overrides it) and the session is
271
275
  # associated with the project.
272
276
  def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
273
- source: :manual, display_message: nil, hidden: false, project_id: nil)
277
+ source: :manual, display_message: nil, project_id: nil)
274
278
  error!("server not ready", status: 503) unless @http_server
275
279
 
280
+ src = source.to_s
281
+ unless ALLOWED_SESSION_SOURCES.include?(src)
282
+ error!("invalid source '#{src}': allowed values are #{ALLOWED_SESSION_SOURCES.join(", ")}", status: 400)
283
+ end
284
+
276
285
  if project_id
277
286
  project_id = project_id.to_s.strip
278
287
  project_id = nil if project_id.empty?
@@ -288,8 +297,7 @@ module Clacky
288
297
  name: name,
289
298
  working_dir: working_dir,
290
299
  profile: profile,
291
- source: source,
292
- hidden: hidden
300
+ source: source
293
301
  )
294
302
 
295
303
  if project_id
@@ -65,17 +65,10 @@ module Clacky
65
65
  existing = reload ? Clacky::ApiExtension.registry[ext_id] : nil
66
66
  existing&.reset_routes!
67
67
 
68
- if reload
69
- old_verbose = $VERBOSE
70
- $VERBOSE = nil
71
- begin
72
- load(handler_path)
73
- ensure
74
- $VERBOSE = old_verbose
75
- end
76
- else
77
- require(handler_path)
78
- end
68
+ # Wrapped load: each handler gets a fresh anonymous module, so two exts
69
+ # defining the same top-level class/constant names cannot clobber each
70
+ # other (and the `inherited` hook fires even on repeat loads).
71
+ load(handler_path, true)
79
72
 
80
73
  new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
81
74
  klass = new_subclasses.last || existing
@@ -16,7 +16,7 @@ module Clacky
16
16
  # optional URL fetch in `install`.
17
17
  module ExtensionPackager
18
18
  MANIFEST = "ext.yml"
19
- MAX_ZIP_SIZE = 50 * 1024 * 1024
19
+ MAX_ZIP_SIZE = 80 * 1024 * 1024
20
20
 
21
21
  # Network timeouts for the download step. `read_timeout` is an *idle*
22
22
  # timeout (resets on every chunk received), so a stalled connection with
@@ -16,7 +16,7 @@ module Clacky
16
16
  module ExtensionVerifier
17
17
  Issue = Struct.new(:ext, :unit, :level, :code, :message, :file, :hint, keyword_init: true)
18
18
 
19
- KNOWN_TOP_KEYS = %w[id name title description version origin author homepage license public license_required keywords contributes].freeze
19
+ KNOWN_TOP_KEYS = %w[id name name_zh display_name display_name_zh title description description_zh emoji version origin author homepage license public license_required keywords contributes].freeze
20
20
  KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks].freeze
21
21
 
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze