openclacky 1.5.4 → 1.5.6

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/CHANGELOG.md +63 -0
  3. data/lib/clacky/agent/goal_manager.rb +305 -0
  4. data/lib/clacky/agent/goal_state.rb +88 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +4 -1
  6. data/lib/clacky/agent/session_serializer.rb +27 -3
  7. data/lib/clacky/agent.rb +202 -3
  8. data/lib/clacky/agent_config.rb +4 -2
  9. data/lib/clacky/agent_profile.rb +25 -3
  10. data/lib/clacky/cli.rb +15 -15
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +4 -4
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +130 -59
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +12 -4
  15. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  16. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  17. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  18. data/lib/clacky/default_searchers/tavily.rb +98 -0
  19. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  20. data/lib/clacky/extension/api_extension.rb +29 -1
  21. data/lib/clacky/extension/loader.rb +1 -0
  22. data/lib/clacky/extension/packager.rb +114 -20
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/idle_compression_timer.rb +1 -3
  25. data/lib/clacky/message_format/anthropic.rb +1 -1
  26. data/lib/clacky/message_format/bedrock.rb +1 -1
  27. data/lib/clacky/message_format/open_ai.rb +8 -0
  28. data/lib/clacky/providers.rb +65 -8
  29. data/lib/clacky/search_config.rb +57 -0
  30. data/lib/clacky/server/http_server.rb +249 -31
  31. data/lib/clacky/server/session_registry.rb +10 -3
  32. data/lib/clacky/server/web_ui_controller.rb +12 -2
  33. data/lib/clacky/session_manager.rb +1 -0
  34. data/lib/clacky/tools/terminal.rb +30 -3
  35. data/lib/clacky/tools/web_search.rb +45 -6
  36. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  37. data/lib/clacky/ui2/components/input_area.rb +26 -2
  38. data/lib/clacky/ui2/ui_controller.rb +17 -0
  39. data/lib/clacky/ui_interface.rb +9 -0
  40. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  41. data/lib/clacky/utils/model_pricing.rb +0 -10
  42. data/lib/clacky/utils/searcher_manager.rb +87 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +530 -197
  45. data/lib/clacky/web/app.js +28 -3
  46. data/lib/clacky/web/components/chat-navigator.js +260 -0
  47. data/lib/clacky/web/features/billing/view.js +2 -2
  48. data/lib/clacky/web/features/brand/view.js +5 -1
  49. data/lib/clacky/web/features/extensions/store.js +214 -9
  50. data/lib/clacky/web/features/extensions/view.js +158 -13
  51. data/lib/clacky/web/features/new-session/store.js +25 -3
  52. data/lib/clacky/web/features/new-session/view.js +337 -140
  53. data/lib/clacky/web/features/skills/store.js +4 -2
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/i18n.js +72 -18
  56. data/lib/clacky/web/index.html +71 -64
  57. data/lib/clacky/web/projects.js +36 -19
  58. data/lib/clacky/web/sessions.js +173 -8
  59. data/lib/clacky/web/settings.js +85 -0
  60. data/lib/clacky/web/skills.js +24 -4
  61. data/lib/clacky/web/utils.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +63 -3
  63. data/lib/clacky.rb +2 -0
  64. metadata +7 -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
@@ -53,7 +55,7 @@ module Clacky
53
55
  attr_accessor :channel_info
54
56
  attr_accessor :project_id
55
57
 
56
- REASONING_EFFORTS = %w[low medium high xhigh].freeze
58
+ REASONING_EFFORTS = %w[low medium high xhigh max].freeze
57
59
 
58
60
  def permission_mode
59
61
  @config&.permission_mode&.to_s || ""
@@ -169,6 +171,9 @@ module Clacky
169
171
 
170
172
  # Ensure bundled shell scripts are in place (~/.clacky/scripts/)
171
173
  Utils::ScriptsManager.setup!
174
+
175
+ # Ensure bundled search providers are in place (~/.clacky/searchers/)
176
+ Utils::SearcherManager.setup!
172
177
  end
173
178
 
174
179
  # Restore from a saved session
@@ -264,6 +269,170 @@ module Clacky
264
269
  @config.effective_model_name
265
270
  end
266
271
 
272
+ # ── /goal (Ralph-style standing goal loop) ────────────────────────────
273
+
274
+ # Lazily-built GoalManager bound to this session. The judge routes through
275
+ # this agent's Client on a lightweight model (the provider's lite model
276
+ # when available, else the primary model) — a cheap side call that never
277
+ # touches conversation history.
278
+ def goal_manager
279
+ @goal_manager ||= GoalManager.new(
280
+ judge_client: @client,
281
+ judge_model: judge_model_name
282
+ )
283
+ end
284
+
285
+ # True if a standing goal loop is active for this session.
286
+ def goal_active?
287
+ @goal_manager&.active? || false
288
+ end
289
+
290
+ # Resolve the model name the goal judge should use. Prefer the provider's
291
+ # lite model (cheap/fast for a one-line verdict); fall back to the primary
292
+ # model when no lite is resolvable.
293
+ private def judge_model_name
294
+ lite = @config.lite_model_config_for_current
295
+ (lite && lite["model"]) || current_model
296
+ end
297
+
298
+ # Parse and handle a /goal command typed by the user. Returns a hash:
299
+ # { handled: true, result: <lightweight result> } — command consumed, no turn
300
+ # { handled: false, user_input: <text|nil> } — fall through to a normal turn
301
+ # (user_input set when `/goal <text>` seeds the first working prompt)
302
+ private def handle_goal_command(user_input)
303
+ text = user_input.to_s.strip
304
+ return { handled: false } unless text.start_with?("/goal", "/subgoal")
305
+
306
+ _cmd, rest = text.split(/\s+/, 2)
307
+ rest = rest.to_s.strip
308
+
309
+ # Sub-command dispatch: status/show/pause/resume/clear take no goal text.
310
+ case rest.downcase
311
+ when "status", "show"
312
+ return goal_command_reply(goal_manager.status_line)
313
+ when "pause"
314
+ goal_manager.pause(reason: "user-paused")
315
+ broadcast_goal_status
316
+ exit_goal_permission_mode!
317
+ return goal_command_reply(goal_manager.status_line)
318
+ when "resume"
319
+ if goal_manager.state.nil?
320
+ return goal_command_reply("No goal to resume. Set one with /goal <text>.")
321
+ end
322
+ goal_manager.resume
323
+ broadcast_goal_status
324
+ enter_goal_permission_mode!
325
+ # Resume runs a fresh working turn immediately.
326
+ return { handled: false, user_input: goal_manager.continuation_prompt }
327
+ when "clear", "stop"
328
+ goal_manager.clear
329
+ broadcast_goal_status
330
+ exit_goal_permission_mode!
331
+ return goal_command_reply("Goal cleared.")
332
+ end
333
+
334
+ # `/goal --turns N <text>` optional budget override.
335
+ max_turns = nil
336
+ if (m = rest.match(/\A--turns\s+(\d+)\s+(.*)\z/m))
337
+ max_turns = m[1].to_i
338
+ rest = m[2].strip
339
+ end
340
+
341
+ if rest.empty?
342
+ return goal_command_reply(goal_manager.status_line)
343
+ end
344
+
345
+ goal_manager.set(rest, max_turns: max_turns)
346
+ broadcast_goal_status
347
+ enter_goal_permission_mode!
348
+ @ui&.show_assistant_message("⊙ Goal set: #{rest}", files: [])
349
+ # Fall through: run the first working turn using the goal itself as the prompt.
350
+ { handled: false, user_input: rest }
351
+ end
352
+
353
+ # Show + persist a one-off goal control-plane reply (no LLM turn) and return
354
+ # a lightweight success result compatible with build_result's shape.
355
+ private def goal_command_reply(message)
356
+ @ui&.show_assistant_message(message, files: [])
357
+ { handled: true, result: goal_control_result }
358
+ end
359
+
360
+ private def goal_control_result
361
+ {
362
+ status: :success,
363
+ session_id: @session_id,
364
+ model: current_model,
365
+ provider: current_provider,
366
+ iterations: 0,
367
+ duration_seconds: 0.0,
368
+ total_cost_usd: 0.0,
369
+ cost_source: :estimated,
370
+ cache_stats: @cache_stats,
371
+ history: @history,
372
+ error: nil,
373
+ goal_command: true
374
+ }
375
+ end
376
+
377
+ # After a completed turn, consult the judge and, if the goal should keep
378
+ # going, run the next turn in this thread. Returns the final result of the
379
+ # continued run, or nil when no continuation happened.
380
+ private def maybe_continue_goal(result)
381
+ return nil unless @goal_manager&.active?
382
+
383
+ decision = @goal_manager.evaluate_after_turn(last_assistant_text)
384
+ broadcast_goal_status
385
+
386
+ unless decision[:message].to_s.empty?
387
+ @ui&.show_assistant_message(decision[:message], files: [])
388
+ end
389
+
390
+ unless decision[:should_continue]
391
+ exit_goal_permission_mode!
392
+ return nil
393
+ end
394
+
395
+ return nil unless decision[:continuation_prompt]
396
+
397
+ run(decision[:continuation_prompt])
398
+ end
399
+
400
+ # The agent's final assistant text this turn — what the judge evaluates.
401
+ private def last_assistant_text
402
+ msg = @history.to_a.reverse.find { |m| m[:role].to_s == "assistant" }
403
+ return "" unless msg
404
+ content = msg[:content]
405
+ return content if content.is_a?(String)
406
+ Array(content).filter_map { |c| c.is_a?(Hash) ? (c[:text] || c["text"]) : c }.join("\n")
407
+ end
408
+
409
+ # Emit a goal_status event over the UI bridge (Web UI live update). No-op
410
+ # when the UI adapter does not implement it (e.g. plain CLI).
411
+ private def broadcast_goal_status
412
+ return unless @ui.respond_to?(:show_goal_status)
413
+ @ui.show_goal_status(@goal_manager&.to_h)
414
+ end
415
+
416
+ private def enter_goal_permission_mode!
417
+ return unless @config&.permission_mode
418
+ return if @config.permission_mode == :auto_approve
419
+ @pre_goal_permission_mode = @config.permission_mode
420
+ @config.permission_mode = :auto_approve
421
+ notify_permission_mode_change(:auto_approve)
422
+ end
423
+
424
+ private def exit_goal_permission_mode!
425
+ return unless @pre_goal_permission_mode
426
+ mode = @pre_goal_permission_mode
427
+ @pre_goal_permission_mode = nil
428
+ @config.permission_mode = mode
429
+ notify_permission_mode_change(mode)
430
+ end
431
+
432
+ private def notify_permission_mode_change(mode)
433
+ @ui&.update_permission_mode(mode) if @ui&.respond_to?(:update_permission_mode)
434
+ end
435
+
267
436
  private def current_provider
268
437
  return nil unless @client.respond_to?(:provider_id)
269
438
  @client.provider_id
@@ -275,6 +444,21 @@ module Clacky
275
444
  end
276
445
 
277
446
  def run(user_input, files: [], display_text: nil, created_at: nil)
447
+ # Intercept /goal ... commands before any task/LLM work. Control-plane
448
+ # commands (status/pause/resume/clear) return immediately without a turn;
449
+ # `/goal <text>` sets the goal, then falls through to run the first turn.
450
+ goal_intercept = handle_goal_command(user_input)
451
+ return goal_intercept[:result] if goal_intercept[:handled]
452
+ user_input = goal_intercept[:user_input] if goal_intercept[:user_input]
453
+
454
+ # Auto-clear a finished/paused goal when the user starts a new non-goal
455
+ # task. /goal <text> already replaced the goal above; control commands
456
+ # returned early. The "✓ Goal achieved" line stays in the thread.
457
+ if !goal_intercept[:user_input] && @goal_manager&.state && !@goal_manager.active?
458
+ @goal_manager.clear
459
+ broadcast_goal_status
460
+ end
461
+
278
462
  # Show the "thinking" indicator as early as possible so the user gets
279
463
  # immediate feedback after sending a message. Without this the UI stays
280
464
  # silent during synchronous setup work (system prompt assembly, file
@@ -673,6 +857,18 @@ module Clacky
673
857
  )
674
858
  end
675
859
  @hooks.trigger(:on_complete, result)
860
+
861
+ # Standing-goal loop: after a completed turn, ask the judge whether the
862
+ # goal is met. If not (and budget/health allow), auto-run the next turn
863
+ # in this same thread. Skipped for subagents and interrupts.
864
+ # awaiting_user_feedback (agent ended with '?') is intentionally not
865
+ # checked here - maybe_continue_goal is a no-op when no goal is active,
866
+ # and when one is active the judge decides done/continue, not punctuation.
867
+ unless @is_subagent || task_interrupted
868
+ continuation = maybe_continue_goal(result)
869
+ return continuation if continuation
870
+ end
871
+
676
872
  result
677
873
  rescue Clacky::AgentInterrupted
678
874
  # Mark this run as interrupted so the next run() (e.g. user's
@@ -1063,9 +1259,12 @@ module Clacky
1063
1259
  # Hook: after_tool_use
1064
1260
  @hooks.trigger(:after_tool_use, call, result)
1065
1261
 
1066
- # Update todos display after todo_manager execution
1262
+ # Update todos display after todo_manager execution.
1263
+ # Skip the broadcast for read-only "list" queries — they don't
1264
+ # mutate state, so pushing a WS event just adds noise.
1067
1265
  if call[:name] == "todo_manager"
1068
- @ui&.update_todos(@todos.dup)
1266
+ action = args[:action] || args["action"]
1267
+ @ui&.update_todos(@todos.dup) unless action == "list"
1069
1268
  end
1070
1269
 
1071
1270
  # Special handling for request_user_feedback: emit as interactive feedback card
@@ -159,7 +159,7 @@ module Clacky
159
159
  DEFAULT_MESSAGE_COUNT_THRESHOLD = 200
160
160
 
161
161
  attr_accessor :permission_mode, :max_tokens, :verbose,
162
- :enable_compression, :enable_prompt_caching,
162
+ :enable_compression, :enable_idle_compression, :enable_prompt_caching,
163
163
  :compression_threshold, :message_count_threshold,
164
164
  :models, :current_model_index, :current_model_id,
165
165
  :memory_update_enabled, :skill_evolution,
@@ -173,6 +173,7 @@ module Clacky
173
173
  @max_tokens = options[:max_tokens] || 16384
174
174
  @verbose = options[:verbose] || false
175
175
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
176
+ @enable_idle_compression = options[:enable_idle_compression].nil? ? true : options[:enable_idle_compression]
176
177
  # Enable prompt caching by default for cost savings
177
178
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
178
179
  # Token threshold that triggers proactive history compression. Local models
@@ -411,7 +412,7 @@ module Clacky
411
412
  # Settings keys that are persisted to config.yml.
412
413
  # These map directly to AgentConfig accessors.
413
414
  CONFIG_SETTINGS_KEYS = %w[
414
- enable_compression enable_prompt_caching
415
+ enable_compression enable_idle_compression enable_prompt_caching
415
416
  compression_threshold message_count_threshold
416
417
  memory_update_enabled
417
418
  skill_evolution max_running_agents max_idle_agents
@@ -428,6 +429,7 @@ module Clacky
428
429
  end
429
430
  settings = {
430
431
  "enable_compression" => @enable_compression,
432
+ "enable_idle_compression" => @enable_idle_compression,
431
433
  "enable_prompt_caching" => @enable_prompt_caching,
432
434
  "compression_threshold" => @compression_threshold,
433
435
  "message_count_threshold" => @message_count_threshold,
@@ -53,8 +53,11 @@ module Clacky
53
53
 
54
54
  # List all available agent profiles across user + extension layers.
55
55
  # Precedence on id collision: user override → extension unit.
56
+ # @param recency [Hash<String, Integer>] agent id => epoch of last use,
57
+ # used to order third-party extension agents by most-recently-used
58
+ # instead of their static `order` field.
56
59
  # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }
57
- def self.all
60
+ def self.all(recency: {})
58
61
  out = {}
59
62
 
60
63
  add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
@@ -76,6 +79,7 @@ module Clacky
76
79
  ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
77
80
  ext_result&.agents&.each do |unit|
78
81
  spec = unit.spec || {}
82
+ next if spec["hidden"]
79
83
  title = spec["title"].to_s
80
84
  title = unit.id if title.empty?
81
85
  avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
@@ -102,8 +106,26 @@ module Clacky
102
106
  )
103
107
  end
104
108
 
105
- source_rank = { "user" => 0, "extension" => 1 }
106
- out.values.sort_by { |a| [source_rank[a[:source]] || 9, a[:order] || 999, a[:id]] }
109
+ # Builtin agents always come first, then user overrides, then third-party
110
+ # extension agents — so the "start new session" grid keeps the official
111
+ # agents grouped ahead of anything the user installed from the market.
112
+ # Within the third-party group, agents are ordered by most-recently-used
113
+ # first (via `recency`), falling back to their static `order` field for
114
+ # agents that have never been used yet.
115
+ layer_rank = lambda do |a|
116
+ next 0 if a[:layer] == "builtin"
117
+ next 1 if a[:source] == "user"
118
+
119
+ 2
120
+ end
121
+ out.values.sort_by do |a|
122
+ rank = layer_rank.call(a)
123
+ if rank == 2
124
+ [rank, -(recency[a[:id]] || 0), a[:order] || 999, a[:id]]
125
+ else
126
+ [rank, a[:order] || 999, a[:id]]
127
+ end
128
+ end
107
129
  end
108
130
 
109
131
  private_class_method def self.read_profile_yml(path)
data/lib/clacky/cli.rb CHANGED
@@ -330,7 +330,7 @@ module Clacky
330
330
 
331
331
  # The overlay lives in the session file (not config.yml), so persist it
332
332
  # now — otherwise it would be lost if the user quits before the next task.
333
- session_manager&.save(agent.to_session_data)
333
+ session_manager&.save(agent.to_session_data(updated_at: Time.now))
334
334
 
335
335
  ui_controller.config[:model] = config.model_name
336
336
  ui_controller.update_sessionbar(
@@ -384,7 +384,7 @@ module Clacky
384
384
 
385
385
  # Save session after switch
386
386
  if session_manager
387
- session_manager.save(agent.to_session_data(status: :success))
387
+ session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
388
388
  end
389
389
  rescue StandardError => e
390
390
  ui_controller.show_error("Time Machine failed: #{e.message}")
@@ -659,11 +659,11 @@ module Clacky
659
659
  ui_controller.set_idle_status
660
660
 
661
661
  if exception.is_a?(Clacky::AgentInterrupted)
662
- session_manager&.save(agent.to_session_data(status: :interrupted))
662
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
663
663
  ui_controller.show_warning("Task interrupted by user")
664
664
  else
665
665
  error_message = format_error(exception)
666
- session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
666
+ session_manager&.save(agent.to_session_data(status: :error, error_message: error_message, updated_at: Time.now))
667
667
  code = exception.is_a?(Clacky::InsufficientCreditError) ? exception.error_code : nil
668
668
  ui_controller.show_error("Error: #{exception.message}", code: code)
669
669
  end
@@ -682,7 +682,7 @@ module Clacky
682
682
  begin
683
683
  files = prepare_non_interactive_files(file_paths)
684
684
  rescue => e
685
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
685
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
686
686
 
687
687
  if is_json
688
688
  ui = Clacky::JsonUIController.new
@@ -719,14 +719,14 @@ module Clacky
719
719
  begin
720
720
  auto_name_session(agent, message)
721
721
  agent.run(message, files: files)
722
- session_manager&.save(agent.to_session_data(status: :success))
722
+ session_manager&.save(agent.to_session_data(status: :success, updated_at: Time.now))
723
723
  exit(0)
724
724
  rescue Clacky::AgentInterrupted
725
- session_manager&.save(agent.to_session_data(status: :interrupted))
725
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
726
726
  $stderr.puts "\nInterrupted."
727
727
  exit(1)
728
728
  rescue => e
729
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
729
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
730
730
  $stderr.puts "Error: #{e.message}"
731
731
  exit(1)
732
732
  end
@@ -801,7 +801,7 @@ module Clacky
801
801
 
802
802
  # Final session save and shutdown
803
803
  if session_manager && agent.total_tasks > 0
804
- session_manager.save(agent.to_session_data(status: :exited))
804
+ session_manager.save(agent.to_session_data(status: :exited, updated_at: Time.now))
805
805
  end
806
806
  json_ui.emit("done", total_cost: agent.total_cost, total_tasks: agent.total_tasks)
807
807
  end
@@ -810,15 +810,15 @@ module Clacky
810
810
  def run_json_task(agent, json_ui, session_manager)
811
811
  json_ui.set_working_status
812
812
  yield
813
- session_manager&.save(agent.to_session_data(status: :success))
813
+ session_manager&.save(agent.to_session_data(status: :success, updated_at: Time.now))
814
814
  json_ui.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
815
815
  :success
816
816
  rescue Clacky::AgentInterrupted
817
- session_manager&.save(agent.to_session_data(status: :interrupted))
817
+ session_manager&.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
818
818
  json_ui.emit("interrupted")
819
819
  :interrupted
820
820
  rescue => e
821
- session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
821
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e), updated_at: Time.now))
822
822
  json_ui.emit("error", message: e.message)
823
823
  :error
824
824
  ensure
@@ -950,7 +950,7 @@ module Clacky
950
950
 
951
951
  # Save final session state before exit
952
952
  if session_manager && agent.total_tasks > 0
953
- session_data = agent.to_session_data(status: :exited)
953
+ session_data = agent.to_session_data(status: :exited, updated_at: Time.now)
954
954
  saved_path = session_manager.save(session_data)
955
955
 
956
956
  # Show session saved message in output area (before stopping UI)
@@ -1059,7 +1059,7 @@ module Clacky
1059
1059
 
1060
1060
  # Save session after each task
1061
1061
  if session_manager
1062
- session_manager.save(agent.to_session_data(status: :success))
1062
+ session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
1063
1063
  end
1064
1064
 
1065
1065
  # Update session bar with agent's cumulative stats
@@ -1100,7 +1100,7 @@ module Clacky
1100
1100
 
1101
1101
  # Save final session state
1102
1102
  if session_manager && agent.total_tasks > 0
1103
- session_manager.save(agent.to_session_data)
1103
+ session_manager.save(agent.to_session_data(updated_at: Time.now))
1104
1104
  end
1105
1105
  end
1106
1106
 
@@ -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
@@ -19,8 +19,8 @@ contributes:
19
19
  - id: ext-developer
20
20
  title: Extension Developer
21
21
  title_zh: 扩展开发
22
- description: AI expert that helps you build, debug, and publish extensions
23
- description_zh: 帮你开发、调试、发布扩展的 AI 专家
22
+ description: AI expert that helps you build custom AI agent workflows
23
+ description_zh: 帮你开发自定义 AI Agent 工作流的专家
24
24
  order: 5
25
25
  prompt: agents/ext-developer/system_prompt.md
26
26
  avatar: agents/ext-developer/avatar.png