openclacky 1.5.6 → 1.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +26 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/hook_manager.rb +14 -3
  15. data/lib/clacky/agent/memory_updater.rb +17 -18
  16. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  17. data/lib/clacky/agent/session_serializer.rb +136 -22
  18. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  19. data/lib/clacky/agent/skill_evolution.rb +11 -7
  20. data/lib/clacky/agent/skill_manager.rb +20 -18
  21. data/lib/clacky/agent/skill_reflector.rb +3 -6
  22. data/lib/clacky/agent.rb +111 -20
  23. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  24. data/lib/clacky/cli.rb +4 -0
  25. data/lib/clacky/client.rb +1 -1
  26. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
  27. data/lib/clacky/extension/api_extension.rb +11 -3
  28. data/lib/clacky/fanout.rb +100 -0
  29. data/lib/clacky/locales/en.rb +2 -0
  30. data/lib/clacky/locales/zh.rb +2 -0
  31. data/lib/clacky/mcp/cli_commands.rb +61 -0
  32. data/lib/clacky/mcp/client.rb +18 -2
  33. data/lib/clacky/mcp/connection_manager.rb +85 -0
  34. data/lib/clacky/mcp/http_transport.rb +25 -9
  35. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  36. data/lib/clacky/mcp/oauth/config.rb +51 -0
  37. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  38. data/lib/clacky/mcp/oauth/session.rb +88 -0
  39. data/lib/clacky/message_format/anthropic.rb +27 -13
  40. data/lib/clacky/message_history.rb +32 -1
  41. data/lib/clacky/providers.rb +11 -5
  42. data/lib/clacky/server/http_server.rb +42 -22
  43. data/lib/clacky/server/session_registry.rb +11 -15
  44. data/lib/clacky/server/web_ui_controller.rb +1 -1
  45. data/lib/clacky/session_manager.rb +13 -11
  46. data/lib/clacky/shell_hook_loader.rb +14 -8
  47. data/lib/clacky/tools/invoke_skill.rb +3 -2
  48. data/lib/clacky/tools/terminal.rb +11 -4
  49. data/lib/clacky/ui2/progress_handle.rb +27 -3
  50. data/lib/clacky/ui2/ui_controller.rb +206 -6
  51. data/lib/clacky/ui_interface.rb +13 -3
  52. data/lib/clacky/utils/arguments_parser.rb +73 -4
  53. data/lib/clacky/utils/model_pricing.rb +21 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +10 -3
  56. data/lib/clacky/web/features/new-session/view.js +19 -2
  57. data/lib/clacky/web/i18n.js +22 -8
  58. data/lib/clacky/web/index.html +7 -11
  59. data/lib/clacky/web/projects.js +26 -0
  60. data/lib/clacky/web/sessions.js +169 -119
  61. data/lib/clacky/web/settings.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +61 -34
  63. data/lib/clacky.rb +2 -0
  64. metadata +12 -1
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
- @pending_subagent_transcript = nil # Subagent trail to attach to the next tool result (observe)
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
- attach_pending_subagent_transcript(response)
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 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
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
- @pending_subagent_transcript = nil
1439
+ Array(response[:tool_calls]).each do |tc|
1440
+ next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
1409
1441
 
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
1442
+ transcript = @pending_subagent_transcripts.delete(tc[:id])
1443
+ next unless transcript
1413
1444
 
1414
- @history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
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
- @total_cost += result[:total_cost_usd] || 0.0
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
- 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
@@ -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 Clacky::Error,
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, 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
@@ -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
@@ -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",
@@ -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
@@ -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