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
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
|
-
@
|
|
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
|
-
|
|
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.
|
|
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
|
|
1400
|
-
# to
|
|
1401
|
-
# transcript rides on
|
|
1402
|
-
# field stripped before the LLM call but persisted to session.json
|
|
1403
|
-
# replayed to the WebUI.
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1438
|
+
Array(response[:tool_calls]).each do |tc|
|
|
1439
|
+
next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
|
|
1409
1440
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
return unless target_id
|
|
1441
|
+
transcript = @pending_subagent_transcripts.delete(tc[:id])
|
|
1442
|
+
next unless transcript
|
|
1413
1443
|
|
|
1414
|
-
|
|
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
|
-
|
|
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.
|
|
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 = []
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -633,16 +633,10 @@ module Clacky
|
|
|
633
633
|
}.compact
|
|
634
634
|
end
|
|
635
635
|
|
|
636
|
-
#
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
homepage license public
|
|
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):
|
|
@@ -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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
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
|