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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +9 -0
- 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 +111 -20
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -0
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
- data/lib/clacky/extension/api_extension.rb +11 -3
- 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/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +11 -5
- data/lib/clacky/server/http_server.rb +42 -22
- 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 +13 -11
- 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/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +206 -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 +10 -3
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/i18n.js +22 -8
- data/lib/clacky/web/index.html +7 -11
- data/lib/clacky/web/projects.js +26 -0
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
data/lib/clacky/agent.rb
CHANGED
|
@@ -51,7 +51,6 @@ module Clacky
|
|
|
51
51
|
:latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
|
|
52
52
|
:reasoning_effort
|
|
53
53
|
attr_accessor :pinned
|
|
54
|
-
attr_accessor :hidden
|
|
55
54
|
attr_accessor :channel_info
|
|
56
55
|
attr_accessor :project_id
|
|
57
56
|
|
|
@@ -82,15 +81,15 @@ module Clacky
|
|
|
82
81
|
@source = source.to_sym # :manual | :cron | :channel
|
|
83
82
|
@channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
|
|
84
83
|
@tool_registry = ToolRegistry.new
|
|
85
|
-
@hooks = HookManager.new
|
|
84
|
+
@hooks = HookManager.new(agent: self)
|
|
86
85
|
@session_id = session_id
|
|
87
86
|
@name = ""
|
|
88
87
|
@pinned = false
|
|
89
|
-
@hidden = false
|
|
90
88
|
@history = MessageHistory.new
|
|
91
89
|
@todos = [] # Store todos in memory
|
|
92
90
|
@iterations = 0
|
|
93
91
|
@total_cost = 0.0
|
|
92
|
+
@cost_mutex = Mutex.new
|
|
94
93
|
@cache_stats = {
|
|
95
94
|
cache_creation_input_tokens: 0,
|
|
96
95
|
cache_read_input_tokens: 0,
|
|
@@ -110,7 +109,7 @@ module Clacky
|
|
|
110
109
|
@ui = ui # UIController for direct UI interaction
|
|
111
110
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
112
111
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
113
|
-
@
|
|
112
|
+
@pending_subagent_transcripts = {} # tool_call_id => subagent trail, attached by observe()
|
|
114
113
|
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
|
|
115
114
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
116
115
|
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
@@ -287,6 +286,27 @@ module Clacky
|
|
|
287
286
|
@goal_manager&.active? || false
|
|
288
287
|
end
|
|
289
288
|
|
|
289
|
+
# Emit a custom extension event to the UI.
|
|
290
|
+
#
|
|
291
|
+
# `type` must be namespaced "ext.<extension>.<event>" so custom events can
|
|
292
|
+
# never collide with the built-in protocol.
|
|
293
|
+
#
|
|
294
|
+
# Transient by default: progress ticks and other high-frequency chatter are
|
|
295
|
+
# pushed live and forgotten, so extensions cannot bloat session.json without
|
|
296
|
+
# opting in. Pass `persist: true` for milestone events that must reappear in
|
|
297
|
+
# the chat stream after a reload; those are anchored to the current message,
|
|
298
|
+
# survive compression via the chunk MD, and are replayed in place.
|
|
299
|
+
def emit_event(type, persist: false, **data)
|
|
300
|
+
name = type.to_s
|
|
301
|
+
unless name.start_with?("ext.")
|
|
302
|
+
raise ArgumentError, "custom event type must be namespaced as 'ext.<extension>.<event>', got #{name.inspect}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
@ui&.emit(name, **data)
|
|
306
|
+
@history.append_ext_event({ type: name, data: data }) if persist
|
|
307
|
+
self
|
|
308
|
+
end
|
|
309
|
+
|
|
290
310
|
# Resolve the model name the goal judge should use. Prefer the provider's
|
|
291
311
|
# lite model (cheap/fast for a one-line verdict); fall back to the primary
|
|
292
312
|
# model when no lite is resolvable.
|
|
@@ -1139,6 +1159,13 @@ module Clacky
|
|
|
1139
1159
|
next
|
|
1140
1160
|
end
|
|
1141
1161
|
|
|
1162
|
+
# A hook fulfilled the call itself (e.g. an extension that runs the
|
|
1163
|
+
# skill on its own subagents). Its result stands in for the tool's.
|
|
1164
|
+
if hook_result[:action] == :handled
|
|
1165
|
+
results << build_success_result(call, hook_result[:result])
|
|
1166
|
+
next
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1142
1169
|
# Show preview for edit and write tools even in auto-approve mode
|
|
1143
1170
|
if should_auto_execute?(call[:name], call[:arguments])
|
|
1144
1171
|
# In auto-approve mode, show preview for edit and write tools
|
|
@@ -1196,10 +1223,13 @@ module Clacky
|
|
|
1196
1223
|
args[:todos_storage] = @todos
|
|
1197
1224
|
end
|
|
1198
1225
|
|
|
1199
|
-
# Special handling for InvokeSkill: inject agent and skill_loader
|
|
1226
|
+
# Special handling for InvokeSkill: inject agent and skill_loader.
|
|
1227
|
+
# tool_call_id lets the subagent transcript be keyed to *this* call, so
|
|
1228
|
+
# multiple invoke_skill calls in one turn don't cross-attach trails.
|
|
1200
1229
|
if call[:name] == "invoke_skill"
|
|
1201
1230
|
args[:agent] = self
|
|
1202
1231
|
args[:skill_loader] = @skill_loader
|
|
1232
|
+
args[:tool_call_id] = call[:id]
|
|
1203
1233
|
end
|
|
1204
1234
|
|
|
1205
1235
|
# Inject working_dir so tools don't rely on Dir.chdir global state
|
|
@@ -1351,7 +1381,7 @@ module Clacky
|
|
|
1351
1381
|
@history.append(truncated.merge(task_id: @current_task_id))
|
|
1352
1382
|
end
|
|
1353
1383
|
|
|
1354
|
-
|
|
1384
|
+
attach_pending_subagent_transcripts(response)
|
|
1355
1385
|
|
|
1356
1386
|
# Append a follow-up `role:"user"` message for any image payloads that
|
|
1357
1387
|
# could not be delivered inside the tool message.
|
|
@@ -1396,22 +1426,24 @@ module Clacky
|
|
|
1396
1426
|
end
|
|
1397
1427
|
end
|
|
1398
1428
|
|
|
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
|
-
|
|
1429
|
+
# Attach captured subagent transcripts (set by execute_skill_with_subagent)
|
|
1430
|
+
# to their own invoke_skill tool result messages just appended by observe().
|
|
1431
|
+
# Each transcript rides on its tool message as :subagent_transcript — an
|
|
1432
|
+
# internal field stripped before the LLM call but persisted to session.json
|
|
1433
|
+
# and replayed to the WebUI. Keyed by tool_call_id so a turn with several
|
|
1434
|
+
# invoke_skill calls attaches each subagent's trail to the call that spawned
|
|
1435
|
+
# it. Consumed entries are deleted so each fires exactly once.
|
|
1436
|
+
private def attach_pending_subagent_transcripts(response)
|
|
1437
|
+
return if @pending_subagent_transcripts.empty?
|
|
1407
1438
|
|
|
1408
|
-
|
|
1439
|
+
Array(response[:tool_calls]).each do |tc|
|
|
1440
|
+
next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
|
|
1409
1441
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
return unless target_id
|
|
1442
|
+
transcript = @pending_subagent_transcripts.delete(tc[:id])
|
|
1443
|
+
next unless transcript
|
|
1413
1444
|
|
|
1414
|
-
|
|
1445
|
+
@history.attach_to_tool_result(tc[:id], :subagent_transcript, transcript)
|
|
1446
|
+
end
|
|
1415
1447
|
end
|
|
1416
1448
|
|
|
1417
1449
|
# Cap oversized tool result content to keep a single tool message from
|
|
@@ -1587,8 +1619,67 @@ module Clacky
|
|
|
1587
1619
|
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
1588
1620
|
result = subagent.run(task)
|
|
1589
1621
|
|
|
1590
|
-
|
|
1622
|
+
# A detached run stays invisible, so its cost is merged silently — the
|
|
1623
|
+
# sessionbar refresh would be the one thing that gives it away.
|
|
1624
|
+
absorb_subagent_cost(result, notify_ui: false)
|
|
1625
|
+
|
|
1626
|
+
final_assistant_text(subagent, parent_count)
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
# Run several independent tasks on forked subagents at the same time.
|
|
1630
|
+
#
|
|
1631
|
+
# Each subagent is forked on the calling thread (forking deep-copies parent
|
|
1632
|
+
# config + history, which must not race) and only the blocking run is moved
|
|
1633
|
+
# onto the pool. Results keep the order of `tasks`; a task that raises or
|
|
1634
|
+
# overruns the budget yields a failed slot instead of aborting its siblings.
|
|
1635
|
+
#
|
|
1636
|
+
# Subagents run silently by default: they share the parent's session, so a
|
|
1637
|
+
# live UI would interleave several raw transcripts into one chat.
|
|
1638
|
+
#
|
|
1639
|
+
# @param tasks [Array<String>] one prompt per subagent
|
|
1640
|
+
# @param model [String, nil] model for every subagent (nil = current)
|
|
1641
|
+
# @param forbidden_tools [Array<String>] tool names blocked at runtime
|
|
1642
|
+
# @param max_concurrency [Integer] subagents allowed to run at once
|
|
1643
|
+
# @param timeout [Numeric, nil] wall-clock budget for the whole batch
|
|
1644
|
+
# @return [Array<Fanout::Result>] value is the subagent's final reply text
|
|
1645
|
+
def fan_out_subagents(tasks, model: nil, forbidden_tools: [], max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY,
|
|
1646
|
+
timeout: nil)
|
|
1647
|
+
return [] if tasks.empty?
|
|
1648
|
+
|
|
1649
|
+
jobs = tasks.each_with_index.map do |task, index|
|
|
1650
|
+
subagent = fork_subagent(
|
|
1651
|
+
model: model,
|
|
1652
|
+
forbidden_tools: forbidden_tools,
|
|
1653
|
+
system_prompt_suffix: "You are one of several subagents running in parallel on independent tasks. " \
|
|
1654
|
+
"Do your task and return only the requested output. Do not ask follow-up questions."
|
|
1655
|
+
)
|
|
1656
|
+
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
1657
|
+
label = "Subagent #{index + 1}/#{tasks.size}"
|
|
1658
|
+
# Fanout workers are fresh threads, so the epoch that lets the web
|
|
1659
|
+
# broadcaster drop events from superseded tasks has to be carried over
|
|
1660
|
+
# by hand — otherwise interrupted subagents keep writing to the new task.
|
|
1661
|
+
epoch = Thread.current[:task_epoch]
|
|
1662
|
+
|
|
1663
|
+
lambda do
|
|
1664
|
+
Thread.current[:task_epoch] = epoch
|
|
1665
|
+
within_phase(label, kind: "fanout_subagent", concurrent: true) do
|
|
1666
|
+
result = subagent.run(task)
|
|
1667
|
+
absorb_subagent_cost(result, notify_ui: false)
|
|
1668
|
+
final_assistant_text(subagent, parent_count)
|
|
1669
|
+
end
|
|
1670
|
+
end
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(jobs)
|
|
1674
|
+
end
|
|
1675
|
+
|
|
1676
|
+
private def within_phase(label, kind:, concurrent:, &block)
|
|
1677
|
+
return yield unless @ui.respond_to?(:with_phase)
|
|
1678
|
+
|
|
1679
|
+
@ui.with_phase(kind: kind, label: label, concurrent: concurrent, &block)
|
|
1680
|
+
end
|
|
1591
1681
|
|
|
1682
|
+
private def final_assistant_text(subagent, parent_count)
|
|
1592
1683
|
new_messages = subagent.history.to_a[parent_count..] || []
|
|
1593
1684
|
new_messages
|
|
1594
1685
|
.reverse
|
|
@@ -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
|
@@ -8,6 +8,7 @@ require_relative "json_ui_controller"
|
|
|
8
8
|
require_relative "plain_ui_controller"
|
|
9
9
|
require_relative "brand_config"
|
|
10
10
|
require_relative "extension/cli_commands"
|
|
11
|
+
require_relative "mcp/cli_commands"
|
|
11
12
|
|
|
12
13
|
module Clacky
|
|
13
14
|
class CLI < Thor
|
|
@@ -1217,6 +1218,9 @@ module Clacky
|
|
|
1217
1218
|
desc "ext SUBCOMMAND", "Manage extension containers (new, verify, list, pack, install, publish, search)"
|
|
1218
1219
|
subcommand "ext", Clacky::CliExtensionCommands
|
|
1219
1220
|
|
|
1221
|
+
desc "mcp SUBCOMMAND", "Manage MCP server connections and OAuth authorization"
|
|
1222
|
+
subcommand "mcp", Clacky::Mcp::CliCommands
|
|
1223
|
+
|
|
1220
1224
|
desc "billing", "Show billing summary and usage statistics"
|
|
1221
1225
|
long_desc <<-LONGDESC
|
|
1222
1226
|
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
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Runs a batch of blocking jobs on a bounded thread pool and collects their
|
|
5
|
+
# results in the order the jobs were given, regardless of completion order.
|
|
6
|
+
#
|
|
7
|
+
# Built for subagent fan-out: each job blocks inside Agent#run, so the pool is
|
|
8
|
+
# sized for concurrency rather than CPU count. A job that raises is captured
|
|
9
|
+
# as a failed slot instead of tearing down its siblings.
|
|
10
|
+
class Fanout
|
|
11
|
+
Result = Struct.new(:index, :value, :error, :duration, keyword_init: true) do
|
|
12
|
+
def ok?
|
|
13
|
+
error.nil?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
DEFAULT_MAX_CONCURRENCY = 4
|
|
18
|
+
|
|
19
|
+
# @param max_concurrency [Integer] jobs allowed to run at once
|
|
20
|
+
# @param timeout [Numeric, nil] wall-clock budget for the whole batch
|
|
21
|
+
def initialize(max_concurrency: DEFAULT_MAX_CONCURRENCY, timeout: nil)
|
|
22
|
+
raise ArgumentError, "max_concurrency must be positive" unless max_concurrency.to_i.positive?
|
|
23
|
+
|
|
24
|
+
@max_concurrency = max_concurrency.to_i
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param jobs [Array<#call>] each job is invoked with no arguments
|
|
29
|
+
# @return [Array<Result>] one entry per job, aligned to the input order
|
|
30
|
+
def run(jobs)
|
|
31
|
+
return [] if jobs.empty?
|
|
32
|
+
|
|
33
|
+
pending = build_queue(jobs)
|
|
34
|
+
results = Array.new(jobs.size)
|
|
35
|
+
deadline = @timeout && (monotonic_now + @timeout)
|
|
36
|
+
|
|
37
|
+
workers = Array.new([@max_concurrency, jobs.size].min) do
|
|
38
|
+
Thread.new { drain(pending, results, deadline) }
|
|
39
|
+
end
|
|
40
|
+
join_all(workers, deadline)
|
|
41
|
+
|
|
42
|
+
fill_unfinished(results)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private def build_queue(jobs)
|
|
46
|
+
queue = Queue.new
|
|
47
|
+
jobs.each_with_index { |job, index| queue << [index, job] }
|
|
48
|
+
queue
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private def drain(pending, results, deadline)
|
|
52
|
+
# pop(true) raises ThreadError when the queue is drained, which is the
|
|
53
|
+
# only exit condition — workers outlive individual jobs.
|
|
54
|
+
loop do
|
|
55
|
+
index, job = pending.pop(true)
|
|
56
|
+
break if deadline && monotonic_now >= deadline
|
|
57
|
+
|
|
58
|
+
results[index] = invoke(index, job)
|
|
59
|
+
end
|
|
60
|
+
rescue ThreadError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private def invoke(index, job)
|
|
65
|
+
started = monotonic_now
|
|
66
|
+
value = job.call
|
|
67
|
+
Result.new(index: index, value: value, error: nil, duration: monotonic_now - started)
|
|
68
|
+
rescue StandardError, ScriptError => e
|
|
69
|
+
Result.new(index: index, value: nil, error: e, duration: monotonic_now - started)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private def join_all(workers, deadline)
|
|
73
|
+
workers.each do |worker|
|
|
74
|
+
if deadline
|
|
75
|
+
remaining = deadline - monotonic_now
|
|
76
|
+
worker.join(remaining.positive? ? remaining : 0) or worker.kill
|
|
77
|
+
else
|
|
78
|
+
worker.join
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private def fill_unfinished(results)
|
|
84
|
+
results.each_with_index do |result, index|
|
|
85
|
+
next if result
|
|
86
|
+
|
|
87
|
+
results[index] = Result.new(
|
|
88
|
+
index: index,
|
|
89
|
+
value: nil,
|
|
90
|
+
error: FanoutTimeoutError.new("fan-out job #{index} did not finish within #{@timeout}s"),
|
|
91
|
+
duration: @timeout
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def monotonic_now
|
|
97
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Locales
|
|
5
5
|
EN = {
|
|
6
|
+
"phase.skill_evolution" => "Reflecting on this task",
|
|
7
|
+
"phase.skipped" => "skipped",
|
|
6
8
|
"llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
|
|
7
9
|
"llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
|
|
8
10
|
"llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Locales
|
|
5
5
|
ZH = {
|
|
6
|
+
"phase.skill_evolution" => "正在复盘本次任务",
|
|
7
|
+
"phase.skipped" => "已跳过",
|
|
6
8
|
"llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
|
|
7
9
|
"llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
|
|
8
10
|
"llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "connection_manager"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Mcp
|
|
9
|
+
class CliCommands < Thor
|
|
10
|
+
desc "capabilities", "Report MCP client capabilities"
|
|
11
|
+
option :json, type: :boolean, default: false
|
|
12
|
+
def capabilities
|
|
13
|
+
result = { "remote_oauth" => true }
|
|
14
|
+
emit(result, "Remote MCP OAuth: supported")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "login SERVER", "Authorize an OAuth-protected remote MCP server"
|
|
18
|
+
option :json, type: :boolean, default: false
|
|
19
|
+
def login(server)
|
|
20
|
+
result = manager(server).login
|
|
21
|
+
emit(result, "Connected MCP server '#{server}'.")
|
|
22
|
+
rescue ConnectionManager::Error => e
|
|
23
|
+
fail_with(e.message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "status SERVER", "Show remote MCP authorization status"
|
|
27
|
+
option :json, type: :boolean, default: false
|
|
28
|
+
def status(server)
|
|
29
|
+
result = manager(server).status
|
|
30
|
+
label = result["connected"] ? "connected" : "disconnected"
|
|
31
|
+
emit(result, "MCP server '#{server}' is #{label}.")
|
|
32
|
+
rescue ConnectionManager::Error => e
|
|
33
|
+
fail_with(e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "logout SERVER", "Delete credentials for a remote MCP server"
|
|
37
|
+
option :json, type: :boolean, default: false
|
|
38
|
+
def logout(server)
|
|
39
|
+
result = manager(server).logout
|
|
40
|
+
emit(result, "Disconnected MCP server '#{server}'.")
|
|
41
|
+
rescue ConnectionManager::Error => e
|
|
42
|
+
fail_with(e.message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
no_commands do
|
|
46
|
+
private def manager(server)
|
|
47
|
+
ConnectionManager.new(server_name: server)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private def emit(result, text)
|
|
51
|
+
puts(options[:json] ? JSON.generate(result) : text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def fail_with(message)
|
|
55
|
+
warn message
|
|
56
|
+
raise Thor::Error, message
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/clacky/mcp/client.rb
CHANGED
|
@@ -6,6 +6,10 @@ require "monitor"
|
|
|
6
6
|
require_relative "transport"
|
|
7
7
|
require_relative "stdio_transport"
|
|
8
8
|
require_relative "http_transport"
|
|
9
|
+
require_relative "oauth/config"
|
|
10
|
+
require_relative "oauth/credential_store"
|
|
11
|
+
require_relative "oauth/authorization_manager"
|
|
12
|
+
require_relative "oauth/session"
|
|
9
13
|
|
|
10
14
|
module Clacky
|
|
11
15
|
module Mcp
|
|
@@ -29,7 +33,7 @@ module Clacky
|
|
|
29
33
|
# Build a Client from an mcp.json spec hash.
|
|
30
34
|
# Recognized fields:
|
|
31
35
|
# stdio: command (required), args, env, cwd
|
|
32
|
-
# http: type: "http", url (required), headers
|
|
36
|
+
# http: type: "http", url (required), headers, auth
|
|
33
37
|
def self.from_spec(name, spec)
|
|
34
38
|
type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
|
|
35
39
|
case type
|
|
@@ -45,12 +49,24 @@ module Clacky
|
|
|
45
49
|
)
|
|
46
50
|
)
|
|
47
51
|
when "http", "streamable-http"
|
|
52
|
+
oauth_config = OAuth::Config.from_server_spec(spec)
|
|
53
|
+
authorization = nil
|
|
54
|
+
if oauth_config.enabled?
|
|
55
|
+
store = OAuth::CredentialStore.new(server_name: name)
|
|
56
|
+
manager = OAuth::AuthorizationManager.new(
|
|
57
|
+
server_name: name,
|
|
58
|
+
config: oauth_config,
|
|
59
|
+
store: store
|
|
60
|
+
)
|
|
61
|
+
authorization = OAuth::Session.new(store: store, manager: manager)
|
|
62
|
+
end
|
|
48
63
|
new(
|
|
49
64
|
name: name,
|
|
50
65
|
transport: HttpTransport.new(
|
|
51
66
|
name: name,
|
|
52
67
|
url: spec["url"],
|
|
53
|
-
headers: spec["headers"] || {}
|
|
68
|
+
headers: spec["headers"] || {},
|
|
69
|
+
authorization: authorization
|
|
54
70
|
)
|
|
55
71
|
)
|
|
56
72
|
else
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "oauth/config"
|
|
5
|
+
require_relative "oauth/credential_store"
|
|
6
|
+
require_relative "oauth/authorization_manager"
|
|
7
|
+
require_relative "oauth/session"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module Mcp
|
|
11
|
+
class ConnectionManager
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize(server_name:, home: Dir.home, working_dir: Dir.pwd, session_factory: nil)
|
|
15
|
+
@server_name = server_name.to_s
|
|
16
|
+
@home = File.expand_path(home)
|
|
17
|
+
@working_dir = working_dir && File.expand_path(working_dir)
|
|
18
|
+
@session_factory = session_factory || lambda do |_config, store, manager|
|
|
19
|
+
OAuth::Session.new(store: store, manager: manager)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def login
|
|
24
|
+
session.login
|
|
25
|
+
safe_status
|
|
26
|
+
rescue OAuth::AuthorizationManager::Error, OAuth::Session::Error, OAuth::CredentialStore::Error => e
|
|
27
|
+
raise Error, e.message
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def status
|
|
31
|
+
safe_status
|
|
32
|
+
rescue OAuth::CredentialStore::Error => e
|
|
33
|
+
raise Error, e.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def logout
|
|
37
|
+
session.logout
|
|
38
|
+
{ "server" => @server_name, "connected" => false }
|
|
39
|
+
rescue OAuth::Session::Error, OAuth::CredentialStore::Error => e
|
|
40
|
+
raise Error, e.message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private def safe_status
|
|
44
|
+
session.status.merge("server" => @server_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private def session
|
|
48
|
+
return @session if @session
|
|
49
|
+
|
|
50
|
+
spec = server_spec
|
|
51
|
+
raise Error, "MCP server '#{@server_name}' is not configured" unless spec
|
|
52
|
+
config = OAuth::Config.from_server_spec(spec)
|
|
53
|
+
raise Error, "MCP server '#{@server_name}' is not configured for OAuth" unless config.enabled?
|
|
54
|
+
store = OAuth::CredentialStore.new(server_name: @server_name, home: @home)
|
|
55
|
+
manager = OAuth::AuthorizationManager.new(
|
|
56
|
+
server_name: @server_name,
|
|
57
|
+
config: config,
|
|
58
|
+
store: store
|
|
59
|
+
)
|
|
60
|
+
@session = @session_factory.call(config, store, manager)
|
|
61
|
+
rescue OAuth::Config::Error => e
|
|
62
|
+
raise Error, e.message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private def server_spec
|
|
66
|
+
servers = {}
|
|
67
|
+
config_paths.each do |path|
|
|
68
|
+
next unless File.file?(path)
|
|
69
|
+
document = JSON.parse(File.read(path))
|
|
70
|
+
source = document["mcpServers"] || document["servers"] || {}
|
|
71
|
+
source.each { |name, spec| servers[name.to_s] = spec if spec.is_a?(Hash) }
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
raise Error, "invalid MCP configuration in #{path}"
|
|
74
|
+
end
|
|
75
|
+
servers[@server_name]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private def config_paths
|
|
79
|
+
paths = [File.join(@home, ".clacky", "mcp.json")]
|
|
80
|
+
paths << File.join(@working_dir, ".clacky", "mcp.json") if @working_dir
|
|
81
|
+
paths
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|