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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +58 -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/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- 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 +162 -25
- 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 +312 -22
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +19 -15
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/extension/api_extension.rb +37 -3
- data/lib/clacky/extension/packager.rb +54 -5
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/idle_compression_timer.rb +1 -3
- 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 +15 -13
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +136 -37
- data/lib/clacky/server/session_registry.rb +21 -18
- 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/tools/web_search.rb +45 -6
- data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
- data/lib/clacky/ui2/components/input_area.rb +26 -2
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +223 -6
- data/lib/clacky/ui_interface.rb +22 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/gitignore_parser.rb +7 -1
- data/lib/clacky/utils/model_pricing.rb +21 -10
- data/lib/clacky/utils/searcher_manager.rb +87 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +243 -33
- data/lib/clacky/web/app.js +12 -1
- data/lib/clacky/web/components/chat-navigator.js +260 -0
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/brand/view.js +5 -1
- data/lib/clacky/web/features/extensions/view.js +19 -19
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/features/skills/store.js +4 -2
- data/lib/clacky/web/i18n.js +64 -14
- data/lib/clacky/web/index.html +40 -9
- data/lib/clacky/web/projects.js +62 -19
- data/lib/clacky/web/sessions.js +329 -115
- data/lib/clacky/web/settings.js +86 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/ws-dispatcher.js +86 -35
- data/lib/clacky.rb +4 -0
- 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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1201
|
-
# to
|
|
1202
|
-
# transcript rides on
|
|
1203
|
-
# field stripped before the LLM call but persisted to session.json
|
|
1204
|
-
# replayed to the WebUI.
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1439
|
+
Array(response[:tool_calls]).each do |tc|
|
|
1440
|
+
next unless (tc[:name] || tc.dig(:function, :name)) == "invoke_skill"
|
|
1210
1441
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
return unless target_id
|
|
1442
|
+
transcript = @pending_subagent_transcripts.delete(tc[:id])
|
|
1443
|
+
next unless transcript
|
|
1214
1444
|
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|