openclacky 1.5.5 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +58 -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/goal_manager.rb +305 -0
  15. data/lib/clacky/agent/goal_state.rb +88 -0
  16. data/lib/clacky/agent/hook_manager.rb +14 -3
  17. data/lib/clacky/agent/memory_updater.rb +17 -18
  18. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  19. data/lib/clacky/agent/session_serializer.rb +162 -25
  20. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  21. data/lib/clacky/agent/skill_evolution.rb +11 -7
  22. data/lib/clacky/agent/skill_manager.rb +20 -18
  23. data/lib/clacky/agent/skill_reflector.rb +3 -6
  24. data/lib/clacky/agent.rb +312 -22
  25. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  26. data/lib/clacky/cli.rb +19 -15
  27. data/lib/clacky/client.rb +1 -1
  28. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  29. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
  32. data/lib/clacky/default_searchers/tavily.rb +98 -0
  33. data/lib/clacky/extension/api_extension.rb +37 -3
  34. data/lib/clacky/extension/packager.rb +54 -5
  35. data/lib/clacky/fanout.rb +100 -0
  36. data/lib/clacky/idle_compression_timer.rb +1 -3
  37. data/lib/clacky/locales/en.rb +2 -0
  38. data/lib/clacky/locales/zh.rb +2 -0
  39. data/lib/clacky/mcp/cli_commands.rb +61 -0
  40. data/lib/clacky/mcp/client.rb +18 -2
  41. data/lib/clacky/mcp/connection_manager.rb +85 -0
  42. data/lib/clacky/mcp/http_transport.rb +25 -9
  43. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  44. data/lib/clacky/mcp/oauth/config.rb +51 -0
  45. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  46. data/lib/clacky/mcp/oauth/session.rb +88 -0
  47. data/lib/clacky/message_format/anthropic.rb +27 -13
  48. data/lib/clacky/message_history.rb +32 -1
  49. data/lib/clacky/providers.rb +15 -13
  50. data/lib/clacky/search_config.rb +57 -0
  51. data/lib/clacky/server/http_server.rb +136 -37
  52. data/lib/clacky/server/session_registry.rb +21 -18
  53. data/lib/clacky/server/web_ui_controller.rb +1 -1
  54. data/lib/clacky/session_manager.rb +13 -11
  55. data/lib/clacky/shell_hook_loader.rb +14 -8
  56. data/lib/clacky/tools/invoke_skill.rb +3 -2
  57. data/lib/clacky/tools/terminal.rb +11 -4
  58. data/lib/clacky/tools/web_search.rb +45 -6
  59. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  60. data/lib/clacky/ui2/components/input_area.rb +26 -2
  61. data/lib/clacky/ui2/progress_handle.rb +27 -3
  62. data/lib/clacky/ui2/ui_controller.rb +223 -6
  63. data/lib/clacky/ui_interface.rb +22 -3
  64. data/lib/clacky/utils/arguments_parser.rb +73 -4
  65. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  66. data/lib/clacky/utils/model_pricing.rb +21 -10
  67. data/lib/clacky/utils/searcher_manager.rb +87 -0
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +243 -33
  70. data/lib/clacky/web/app.js +12 -1
  71. data/lib/clacky/web/components/chat-navigator.js +260 -0
  72. data/lib/clacky/web/features/billing/view.js +2 -2
  73. data/lib/clacky/web/features/brand/view.js +5 -1
  74. data/lib/clacky/web/features/extensions/view.js +19 -19
  75. data/lib/clacky/web/features/new-session/view.js +19 -2
  76. data/lib/clacky/web/features/skills/store.js +4 -2
  77. data/lib/clacky/web/i18n.js +64 -14
  78. data/lib/clacky/web/index.html +40 -9
  79. data/lib/clacky/web/projects.js +62 -19
  80. data/lib/clacky/web/sessions.js +329 -115
  81. data/lib/clacky/web/settings.js +86 -0
  82. data/lib/clacky/web/skills.js +24 -4
  83. data/lib/clacky/web/ws-dispatcher.js +86 -35
  84. data/lib/clacky.rb +4 -0
  85. metadata +18 -1
data/lib/clacky/agent.rb CHANGED
@@ -25,6 +25,8 @@ require_relative "agent/skill_evolution"
25
25
  require_relative "agent/skill_reflector"
26
26
  require_relative "agent/skill_auto_creator"
27
27
  require_relative "agent/fake_tool_call_detector"
28
+ require_relative "agent/goal_state"
29
+ require_relative "agent/goal_manager"
28
30
 
29
31
  module Clacky
30
32
  class Agent
@@ -49,7 +51,6 @@ module Clacky
49
51
  :latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
50
52
  :reasoning_effort
51
53
  attr_accessor :pinned
52
- attr_accessor :hidden
53
54
  attr_accessor :channel_info
54
55
  attr_accessor :project_id
55
56
 
@@ -80,15 +81,15 @@ module Clacky
80
81
  @source = source.to_sym # :manual | :cron | :channel
81
82
  @channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
82
83
  @tool_registry = ToolRegistry.new
83
- @hooks = HookManager.new
84
+ @hooks = HookManager.new(agent: self)
84
85
  @session_id = session_id
85
86
  @name = ""
86
87
  @pinned = false
87
- @hidden = false
88
88
  @history = MessageHistory.new
89
89
  @todos = [] # Store todos in memory
90
90
  @iterations = 0
91
91
  @total_cost = 0.0
92
+ @cost_mutex = Mutex.new
92
93
  @cache_stats = {
93
94
  cache_creation_input_tokens: 0,
94
95
  cache_read_input_tokens: 0,
@@ -108,7 +109,7 @@ module Clacky
108
109
  @ui = ui # UIController for direct UI interaction
109
110
  @debug_logs = [] # Debug logs for troubleshooting
110
111
  @pending_injections = [] # Pending inline skill injections to flush after observe()
111
- @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()
112
113
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
113
114
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
114
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)
@@ -169,6 +170,9 @@ module Clacky
169
170
 
170
171
  # Ensure bundled shell scripts are in place (~/.clacky/scripts/)
171
172
  Utils::ScriptsManager.setup!
173
+
174
+ # Ensure bundled search providers are in place (~/.clacky/searchers/)
175
+ Utils::SearcherManager.setup!
172
176
  end
173
177
 
174
178
  # Restore from a saved session
@@ -264,6 +268,191 @@ module Clacky
264
268
  @config.effective_model_name
265
269
  end
266
270
 
271
+ # ── /goal (Ralph-style standing goal loop) ────────────────────────────
272
+
273
+ # Lazily-built GoalManager bound to this session. The judge routes through
274
+ # this agent's Client on a lightweight model (the provider's lite model
275
+ # when available, else the primary model) — a cheap side call that never
276
+ # touches conversation history.
277
+ def goal_manager
278
+ @goal_manager ||= GoalManager.new(
279
+ judge_client: @client,
280
+ judge_model: judge_model_name
281
+ )
282
+ end
283
+
284
+ # True if a standing goal loop is active for this session.
285
+ def goal_active?
286
+ @goal_manager&.active? || false
287
+ end
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
+
310
+ # Resolve the model name the goal judge should use. Prefer the provider's
311
+ # lite model (cheap/fast for a one-line verdict); fall back to the primary
312
+ # model when no lite is resolvable.
313
+ private def judge_model_name
314
+ lite = @config.lite_model_config_for_current
315
+ (lite && lite["model"]) || current_model
316
+ end
317
+
318
+ # Parse and handle a /goal command typed by the user. Returns a hash:
319
+ # { handled: true, result: <lightweight result> } — command consumed, no turn
320
+ # { handled: false, user_input: <text|nil> } — fall through to a normal turn
321
+ # (user_input set when `/goal <text>` seeds the first working prompt)
322
+ private def handle_goal_command(user_input)
323
+ text = user_input.to_s.strip
324
+ return { handled: false } unless text.start_with?("/goal", "/subgoal")
325
+
326
+ _cmd, rest = text.split(/\s+/, 2)
327
+ rest = rest.to_s.strip
328
+
329
+ # Sub-command dispatch: status/show/pause/resume/clear take no goal text.
330
+ case rest.downcase
331
+ when "status", "show"
332
+ return goal_command_reply(goal_manager.status_line)
333
+ when "pause"
334
+ goal_manager.pause(reason: "user-paused")
335
+ broadcast_goal_status
336
+ exit_goal_permission_mode!
337
+ return goal_command_reply(goal_manager.status_line)
338
+ when "resume"
339
+ if goal_manager.state.nil?
340
+ return goal_command_reply("No goal to resume. Set one with /goal <text>.")
341
+ end
342
+ goal_manager.resume
343
+ broadcast_goal_status
344
+ enter_goal_permission_mode!
345
+ # Resume runs a fresh working turn immediately.
346
+ return { handled: false, user_input: goal_manager.continuation_prompt }
347
+ when "clear", "stop"
348
+ goal_manager.clear
349
+ broadcast_goal_status
350
+ exit_goal_permission_mode!
351
+ return goal_command_reply("Goal cleared.")
352
+ end
353
+
354
+ # `/goal --turns N <text>` optional budget override.
355
+ max_turns = nil
356
+ if (m = rest.match(/\A--turns\s+(\d+)\s+(.*)\z/m))
357
+ max_turns = m[1].to_i
358
+ rest = m[2].strip
359
+ end
360
+
361
+ if rest.empty?
362
+ return goal_command_reply(goal_manager.status_line)
363
+ end
364
+
365
+ goal_manager.set(rest, max_turns: max_turns)
366
+ broadcast_goal_status
367
+ enter_goal_permission_mode!
368
+ @ui&.show_assistant_message("⊙ Goal set: #{rest}", files: [])
369
+ # Fall through: run the first working turn using the goal itself as the prompt.
370
+ { handled: false, user_input: rest }
371
+ end
372
+
373
+ # Show + persist a one-off goal control-plane reply (no LLM turn) and return
374
+ # a lightweight success result compatible with build_result's shape.
375
+ private def goal_command_reply(message)
376
+ @ui&.show_assistant_message(message, files: [])
377
+ { handled: true, result: goal_control_result }
378
+ end
379
+
380
+ private def goal_control_result
381
+ {
382
+ status: :success,
383
+ session_id: @session_id,
384
+ model: current_model,
385
+ provider: current_provider,
386
+ iterations: 0,
387
+ duration_seconds: 0.0,
388
+ total_cost_usd: 0.0,
389
+ cost_source: :estimated,
390
+ cache_stats: @cache_stats,
391
+ history: @history,
392
+ error: nil,
393
+ goal_command: true
394
+ }
395
+ end
396
+
397
+ # After a completed turn, consult the judge and, if the goal should keep
398
+ # going, run the next turn in this thread. Returns the final result of the
399
+ # continued run, or nil when no continuation happened.
400
+ private def maybe_continue_goal(result)
401
+ return nil unless @goal_manager&.active?
402
+
403
+ decision = @goal_manager.evaluate_after_turn(last_assistant_text)
404
+ broadcast_goal_status
405
+
406
+ unless decision[:message].to_s.empty?
407
+ @ui&.show_assistant_message(decision[:message], files: [])
408
+ end
409
+
410
+ unless decision[:should_continue]
411
+ exit_goal_permission_mode!
412
+ return nil
413
+ end
414
+
415
+ return nil unless decision[:continuation_prompt]
416
+
417
+ run(decision[:continuation_prompt])
418
+ end
419
+
420
+ # The agent's final assistant text this turn — what the judge evaluates.
421
+ private def last_assistant_text
422
+ msg = @history.to_a.reverse.find { |m| m[:role].to_s == "assistant" }
423
+ return "" unless msg
424
+ content = msg[:content]
425
+ return content if content.is_a?(String)
426
+ Array(content).filter_map { |c| c.is_a?(Hash) ? (c[:text] || c["text"]) : c }.join("\n")
427
+ end
428
+
429
+ # Emit a goal_status event over the UI bridge (Web UI live update). No-op
430
+ # when the UI adapter does not implement it (e.g. plain CLI).
431
+ private def broadcast_goal_status
432
+ return unless @ui.respond_to?(:show_goal_status)
433
+ @ui.show_goal_status(@goal_manager&.to_h)
434
+ end
435
+
436
+ private def enter_goal_permission_mode!
437
+ return unless @config&.permission_mode
438
+ return if @config.permission_mode == :auto_approve
439
+ @pre_goal_permission_mode = @config.permission_mode
440
+ @config.permission_mode = :auto_approve
441
+ notify_permission_mode_change(:auto_approve)
442
+ end
443
+
444
+ private def exit_goal_permission_mode!
445
+ return unless @pre_goal_permission_mode
446
+ mode = @pre_goal_permission_mode
447
+ @pre_goal_permission_mode = nil
448
+ @config.permission_mode = mode
449
+ notify_permission_mode_change(mode)
450
+ end
451
+
452
+ private def notify_permission_mode_change(mode)
453
+ @ui&.update_permission_mode(mode) if @ui&.respond_to?(:update_permission_mode)
454
+ end
455
+
267
456
  private def current_provider
268
457
  return nil unless @client.respond_to?(:provider_id)
269
458
  @client.provider_id
@@ -275,6 +464,21 @@ module Clacky
275
464
  end
276
465
 
277
466
  def run(user_input, files: [], display_text: nil, created_at: nil)
467
+ # Intercept /goal ... commands before any task/LLM work. Control-plane
468
+ # commands (status/pause/resume/clear) return immediately without a turn;
469
+ # `/goal <text>` sets the goal, then falls through to run the first turn.
470
+ goal_intercept = handle_goal_command(user_input)
471
+ return goal_intercept[:result] if goal_intercept[:handled]
472
+ user_input = goal_intercept[:user_input] if goal_intercept[:user_input]
473
+
474
+ # Auto-clear a finished/paused goal when the user starts a new non-goal
475
+ # task. /goal <text> already replaced the goal above; control commands
476
+ # returned early. The "✓ Goal achieved" line stays in the thread.
477
+ if !goal_intercept[:user_input] && @goal_manager&.state && !@goal_manager.active?
478
+ @goal_manager.clear
479
+ broadcast_goal_status
480
+ end
481
+
278
482
  # Show the "thinking" indicator as early as possible so the user gets
279
483
  # immediate feedback after sending a message. Without this the UI stays
280
484
  # silent during synchronous setup work (system prompt assembly, file
@@ -673,6 +877,18 @@ module Clacky
673
877
  )
674
878
  end
675
879
  @hooks.trigger(:on_complete, result)
880
+
881
+ # Standing-goal loop: after a completed turn, ask the judge whether the
882
+ # goal is met. If not (and budget/health allow), auto-run the next turn
883
+ # in this same thread. Skipped for subagents and interrupts.
884
+ # awaiting_user_feedback (agent ended with '?') is intentionally not
885
+ # checked here - maybe_continue_goal is a no-op when no goal is active,
886
+ # and when one is active the judge decides done/continue, not punctuation.
887
+ unless @is_subagent || task_interrupted
888
+ continuation = maybe_continue_goal(result)
889
+ return continuation if continuation
890
+ end
891
+
676
892
  result
677
893
  rescue Clacky::AgentInterrupted
678
894
  # Mark this run as interrupted so the next run() (e.g. user's
@@ -943,6 +1159,13 @@ module Clacky
943
1159
  next
944
1160
  end
945
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
+
946
1169
  # Show preview for edit and write tools even in auto-approve mode
947
1170
  if should_auto_execute?(call[:name], call[:arguments])
948
1171
  # In auto-approve mode, show preview for edit and write tools
@@ -1000,10 +1223,13 @@ module Clacky
1000
1223
  args[:todos_storage] = @todos
1001
1224
  end
1002
1225
 
1003
- # 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.
1004
1229
  if call[:name] == "invoke_skill"
1005
1230
  args[:agent] = self
1006
1231
  args[:skill_loader] = @skill_loader
1232
+ args[:tool_call_id] = call[:id]
1007
1233
  end
1008
1234
 
1009
1235
  # Inject working_dir so tools don't rely on Dir.chdir global state
@@ -1063,9 +1289,12 @@ module Clacky
1063
1289
  # Hook: after_tool_use
1064
1290
  @hooks.trigger(:after_tool_use, call, result)
1065
1291
 
1066
- # Update todos display after todo_manager execution
1292
+ # Update todos display after todo_manager execution.
1293
+ # Skip the broadcast for read-only "list" queries — they don't
1294
+ # mutate state, so pushing a WS event just adds noise.
1067
1295
  if call[:name] == "todo_manager"
1068
- @ui&.update_todos(@todos.dup)
1296
+ action = args[:action] || args["action"]
1297
+ @ui&.update_todos(@todos.dup) unless action == "list"
1069
1298
  end
1070
1299
 
1071
1300
  # Special handling for request_user_feedback: emit as interactive feedback card
@@ -1152,7 +1381,7 @@ module Clacky
1152
1381
  @history.append(truncated.merge(task_id: @current_task_id))
1153
1382
  end
1154
1383
 
1155
- attach_pending_subagent_transcript(response)
1384
+ attach_pending_subagent_transcripts(response)
1156
1385
 
1157
1386
  # Append a follow-up `role:"user"` message for any image payloads that
1158
1387
  # could not be delivered inside the tool message.
@@ -1197,22 +1426,24 @@ module Clacky
1197
1426
  end
1198
1427
  end
1199
1428
 
1200
- # Attach the captured subagent transcript (set by execute_skill_with_subagent)
1201
- # to the invoke_skill tool result message just appended by observe(). The
1202
- # transcript rides on the tool message as :subagent_transcript — an internal
1203
- # field stripped before the LLM call but persisted to session.json and
1204
- # replayed to the WebUI. Cleared after attaching so it fires exactly once.
1205
- private def attach_pending_subagent_transcript(response)
1206
- transcript = @pending_subagent_transcript
1207
- 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?
1208
1438
 
1209
- @pending_subagent_transcript = nil
1439
+ Array(response[:tool_calls]).each do |tc|
1440
+ next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
1210
1441
 
1211
- skill_call = Array(response[:tool_calls]).find { |tc| (tc[:name] || tc.dig(:function, :name)) == "invoke_skill" }
1212
- target_id = skill_call && skill_call[:id]
1213
- return unless target_id
1442
+ transcript = @pending_subagent_transcripts.delete(tc[:id])
1443
+ next unless transcript
1214
1444
 
1215
- @history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
1445
+ @history.attach_to_tool_result(tc[:id], :subagent_transcript, transcript)
1446
+ end
1216
1447
  end
1217
1448
 
1218
1449
  # Cap oversized tool result content to keep a single tool message from
@@ -1388,8 +1619,67 @@ module Clacky
1388
1619
  parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1389
1620
  result = subagent.run(task)
1390
1621
 
1391
- @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
1392
1681
 
1682
+ private def final_assistant_text(subagent, parent_count)
1393
1683
  new_messages = subagent.history.to_a[parent_count..] || []
1394
1684
  new_messages
1395
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
@@ -330,7 +331,7 @@ module Clacky
330
331
 
331
332
  # The overlay lives in the session file (not config.yml), so persist it
332
333
  # now — otherwise it would be lost if the user quits before the next task.
333
- session_manager&.save(agent.to_session_data)
334
+ session_manager&.save(agent.to_session_data(updated_at: Time.now))
334
335
 
335
336
  ui_controller.config[:model] = config.model_name
336
337
  ui_controller.update_sessionbar(
@@ -384,7 +385,7 @@ module Clacky
384
385
 
385
386
  # Save session after switch
386
387
  if session_manager
387
- session_manager.save(agent.to_session_data(status: :success))
388
+ session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
388
389
  end
389
390
  rescue StandardError => e
390
391
  ui_controller.show_error("Time Machine failed: #{e.message}")
@@ -659,11 +660,11 @@ module Clacky
659
660
  ui_controller.set_idle_status
660
661
 
661
662
  if exception.is_a?(Clacky::AgentInterrupted)
662
- session_manager&.save(agent.to_session_data(status: :interrupted))
663
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
663
664
  ui_controller.show_warning("Task interrupted by user")
664
665
  else
665
666
  error_message = format_error(exception)
666
- session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
667
+ session_manager&.save(agent.to_session_data(status: :error, error_message: error_message, updated_at: Time.now))
667
668
  code = exception.is_a?(Clacky::InsufficientCreditError) ? exception.error_code : nil
668
669
  ui_controller.show_error("Error: #{exception.message}", code: code)
669
670
  end
@@ -682,7 +683,7 @@ module Clacky
682
683
  begin
683
684
  files = prepare_non_interactive_files(file_paths)
684
685
  rescue => e
685
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
686
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
686
687
 
687
688
  if is_json
688
689
  ui = Clacky::JsonUIController.new
@@ -719,14 +720,14 @@ module Clacky
719
720
  begin
720
721
  auto_name_session(agent, message)
721
722
  agent.run(message, files: files)
722
- session_manager&.save(agent.to_session_data(status: :success))
723
+ session_manager&.save(agent.to_session_data(status: :success, updated_at: Time.now))
723
724
  exit(0)
724
725
  rescue Clacky::AgentInterrupted
725
- session_manager&.save(agent.to_session_data(status: :interrupted))
726
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
726
727
  $stderr.puts "\nInterrupted."
727
728
  exit(1)
728
729
  rescue => e
729
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
730
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
730
731
  $stderr.puts "Error: #{e.message}"
731
732
  exit(1)
732
733
  end
@@ -801,7 +802,7 @@ module Clacky
801
802
 
802
803
  # Final session save and shutdown
803
804
  if session_manager && agent.total_tasks > 0
804
- session_manager.save(agent.to_session_data(status: :exited))
805
+ session_manager.save(agent.to_session_data(status: :exited, updated_at: Time.now))
805
806
  end
806
807
  json_ui.emit("done", total_cost: agent.total_cost, total_tasks: agent.total_tasks)
807
808
  end
@@ -810,15 +811,15 @@ module Clacky
810
811
  def run_json_task(agent, json_ui, session_manager)
811
812
  json_ui.set_working_status
812
813
  yield
813
- session_manager&.save(agent.to_session_data(status: :success))
814
+ session_manager&.save(agent.to_session_data(status: :success, updated_at: Time.now))
814
815
  json_ui.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
815
816
  :success
816
817
  rescue Clacky::AgentInterrupted
817
- session_manager&.save(agent.to_session_data(status: :interrupted))
818
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
818
819
  json_ui.emit("interrupted")
819
820
  :interrupted
820
821
  rescue => e
821
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
822
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
822
823
  json_ui.emit("error", message: e.message)
823
824
  :error
824
825
  ensure
@@ -950,7 +951,7 @@ module Clacky
950
951
 
951
952
  # Save final session state before exit
952
953
  if session_manager && agent.total_tasks > 0
953
- session_data = agent.to_session_data(status: :exited)
954
+ session_data = agent.to_session_data(status: :exited, updated_at: Time.now)
954
955
  saved_path = session_manager.save(session_data)
955
956
 
956
957
  # Show session saved message in output area (before stopping UI)
@@ -1059,7 +1060,7 @@ module Clacky
1059
1060
 
1060
1061
  # Save session after each task
1061
1062
  if session_manager
1062
- session_manager.save(agent.to_session_data(status: :success))
1063
+ session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
1063
1064
  end
1064
1065
 
1065
1066
  # Update session bar with agent's cumulative stats
@@ -1100,7 +1101,7 @@ module Clacky
1100
1101
 
1101
1102
  # Save final session state
1102
1103
  if session_manager && agent.total_tasks > 0
1103
- session_manager.save(agent.to_session_data)
1104
+ session_manager.save(agent.to_session_data(updated_at: Time.now))
1104
1105
  end
1105
1106
  end
1106
1107
 
@@ -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
@@ -75,6 +75,11 @@ class ExtStudioExt < Clacky::ApiExtension
75
75
 
76
76
  brand = Clacky::BrandConfig.load
77
77
 
78
+ origin = presence(json_body["origin"]) || "marketplace"
79
+ if origin == "self" && !brand.user_licensed?
80
+ error!("only brand administrators can publish to a brand", status: 403)
81
+ end
82
+
78
83
  Dir.mktmpdir("clacky-ext-studio-publish") do |tmp|
79
84
  res = Clacky::ExtensionPackager.pack(ext_id, out_dir: tmp)
80
85
  zip_data = File.binread(res.path)
@@ -96,7 +101,8 @@ class ExtStudioExt < Clacky::ApiExtension
96
101
  force: json_body["force"] == true,
97
102
  status: presence(json_body["status"]),
98
103
  changelog: presence(json_body["changelog"]),
99
- readme: local_readme
104
+ readme: local_readme,
105
+ origin: origin
100
106
  )
101
107
 
102
108
  if result[:success]
@@ -140,6 +146,28 @@ class ExtStudioExt < Clacky::ApiExtension
140
146
  json(extensions: exts)
141
147
  end
142
148
 
149
+ # GET /api/ext/ext-studio/published_brand
150
+ # List extensions published to the current brand's library. Only available to
151
+ # brand administrators (user-licensed); used by the publish panel to detect
152
+ # existing brand versions for the "publish to brand" flow.
153
+ get "/published_brand" do
154
+ brand = Clacky::BrandConfig.load
155
+ error!("only brand administrators can list brand extensions", status: 403) unless brand.user_licensed?
156
+
157
+ result = brand.fetch_brand_extensions!
158
+ error!(result[:error] || "failed to fetch brand extensions", status: 502) unless result[:success]
159
+
160
+ exts = Array(result[:extensions]).map do |ext|
161
+ {
162
+ id: ext["name"] || ext["slug"] || ext["id"],
163
+ name: ext["display_name"] || ext["name"],
164
+ version: (ext["latest_version"] || {})["version"] || ext["version"],
165
+ status: ext["status"] || "published"
166
+ }
167
+ end
168
+ json(extensions: exts)
169
+ end
170
+
143
171
  # DELETE /api/ext/ext-studio/local
144
172
  # body: { ext_id }
145
173
  # Permanently removes a local extension directory. Only allowed for
@@ -9,8 +9,8 @@ contributes:
9
9
  api: api/handler.rb
10
10
  panels:
11
11
  - id: studio
12
- title: Extension & Creation
13
- title_zh: 扩展与创作
12
+ title: Creator Center
13
+ title_zh: 创作者中心
14
14
  description: Build, debug and publish extensions and skills
15
15
  description_zh: 开发、调试、发布扩展与 skill
16
16
  view: panels/studio/view.js