openclacky 1.5.18 → 1.5.20

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +1 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/docs/session-observation-hooks.md +86 -0
  7. data/lib/clacky/agent/chunk_index.rb +4 -1
  8. data/lib/clacky/agent/history_navigation.rb +6 -1
  9. data/lib/clacky/agent/hook_manager.rb +11 -4
  10. data/lib/clacky/agent/llm_caller.rb +16 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +8 -0
  12. data/lib/clacky/agent/session_serializer.rb +25 -2
  13. data/lib/clacky/agent.rb +139 -36
  14. data/lib/clacky/billing/platform_billing.rb +10 -0
  15. data/lib/clacky/cli.rb +2 -2
  16. data/lib/clacky/client.rb +7 -0
  17. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +2 -1
  18. data/lib/clacky/default_extensions/advisor/hooks/advisor_complete.rb +3 -2
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +77 -20
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  21. data/lib/clacky/default_skills/channel-manager/SKILL.md +70 -9
  22. data/lib/clacky/default_skills/channel-manager/qq_setup.rb +79 -0
  23. data/lib/clacky/default_skills/media-gen/SKILL.md +45 -14
  24. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  25. data/lib/clacky/locales/en.rb +2 -2
  26. data/lib/clacky/locales/zh.rb +2 -2
  27. data/lib/clacky/media/openai_compat.rb +89 -2
  28. data/lib/clacky/message_format/bedrock.rb +5 -2
  29. data/lib/clacky/message_history.rb +24 -1
  30. data/lib/clacky/providers.rb +54 -144
  31. data/lib/clacky/server/channel/adapters/qq/adapter.rb +662 -0
  32. data/lib/clacky/server/channel/adapters/qq/api_client.rb +245 -0
  33. data/lib/clacky/server/channel/adapters/qq/gateway_client.rb +340 -0
  34. data/lib/clacky/server/channel/adapters/qq/qr_binder.rb +140 -0
  35. data/lib/clacky/server/channel/channel_config.rb +9 -0
  36. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  37. data/lib/clacky/server/channel.rb +1 -0
  38. data/lib/clacky/server/http_server.rb +235 -45
  39. data/lib/clacky/server/model_prices.rb +14 -3
  40. data/lib/clacky/server/session_registry.rb +21 -3
  41. data/lib/clacky/server/web_ui_controller.rb +49 -0
  42. data/lib/clacky/session_manager.rb +21 -5
  43. data/lib/clacky/shell_hook_loader.rb +3 -1
  44. data/lib/clacky/utils/model_pricing.rb +254 -30
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +1133 -308
  47. data/lib/clacky/web/app.js +82 -13
  48. data/lib/clacky/web/components/chat-navigator.js +154 -171
  49. data/lib/clacky/web/components/code-editor.js +6 -2
  50. data/lib/clacky/web/components/composer.js +11 -2
  51. data/lib/clacky/web/components/model-picker.js +167 -64
  52. data/lib/clacky/web/components/sidebar.js +6 -2
  53. data/lib/clacky/web/core/aside.js +72 -0
  54. data/lib/clacky/web/features/archive/store.js +182 -0
  55. data/lib/clacky/web/features/archive/view.js +399 -0
  56. data/lib/clacky/web/features/backup/view.js +12 -2
  57. data/lib/clacky/web/features/billing/store.js +1 -1
  58. data/lib/clacky/web/features/billing/view.js +207 -41
  59. data/lib/clacky/web/features/channels/store.js +25 -0
  60. data/lib/clacky/web/features/channels/view.js +168 -5
  61. data/lib/clacky/web/features/new-session/view.js +1 -0
  62. data/lib/clacky/web/features/skills/view.js +12 -0
  63. data/lib/clacky/web/features/trash/store.js +4 -4
  64. data/lib/clacky/web/features/trash/view.js +57 -9
  65. data/lib/clacky/web/features/workspace/view.js +509 -28
  66. data/lib/clacky/web/i18n.js +186 -48
  67. data/lib/clacky/web/index.html +224 -87
  68. data/lib/clacky/web/projects.js +131 -3
  69. data/lib/clacky/web/sessions.js +416 -131
  70. data/lib/clacky/web/settings.js +62 -24
  71. data/lib/clacky/web/ws-dispatcher.js +24 -8
  72. data/lib/clacky.rb +3 -2
  73. metadata +9 -1
data/lib/clacky/agent.rb CHANGED
@@ -52,6 +52,9 @@ module Clacky
52
52
  attr_accessor :pinned
53
53
  attr_accessor :channel_info
54
54
  attr_accessor :project_id
55
+ # ISO8601 stamp set when the user archives the session; nil = active.
56
+ # Archived sessions stay on disk but drop out of every normal list.
57
+ attr_accessor :archived_at
55
58
 
56
59
  REASONING_EFFORTS = %w[low medium high xhigh max].freeze
57
60
  MAX_VIDEO_UNDERSTANDING_BYTES = Utils::FileProcessor::MAX_FILE_BYTES
@@ -93,6 +96,7 @@ module Clacky
93
96
  @session_id = session_id
94
97
  @name = ""
95
98
  @pinned = false
99
+ @archived_at = nil
96
100
  @history = MessageHistory.new
97
101
  @todos = [] # Store todos in memory
98
102
  @iterations = 0
@@ -329,6 +333,14 @@ module Clacky
329
333
  self
330
334
  end
331
335
 
336
+ # Built-in run failures share the persisted event trail, without exposing
337
+ # built-in event types through the extension API.
338
+ def record_error_event(message, code: nil, top_up_url: nil, raw_message: nil)
339
+ data = { message: message, code: code, top_up_url: top_up_url, raw_message: raw_message }.compact
340
+ @history.append_ext_event({ type: "error", data: data })
341
+ self
342
+ end
343
+
332
344
  # Resolve the model name the goal judge should use. Prefer the provider's
333
345
  # lite model (cheap/fast for a one-line verdict); fall back to the primary
334
346
  # model when no lite is resolvable.
@@ -485,11 +497,13 @@ module Clacky
485
497
  @name = new_name.to_s.strip
486
498
  end
487
499
 
488
- def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
500
+ def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil, feedback: nil)
489
501
  # Initialized here (not mid-body) because run's rescue/ensure are
490
502
  # method-level and must be able to reference them on any exit path.
491
503
  result = nil
492
504
  run_turn_started = false
505
+ completion_sent = false
506
+ run_context = nil
493
507
 
494
508
  # Intercept /goal ... commands before any task/LLM work. Control-plane
495
509
  # commands (status/pause/resume/clear) return immediately without a turn;
@@ -520,6 +534,10 @@ module Clacky
520
534
 
521
535
  # Start new task for Time Machine
522
536
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
537
+ @feedback = feedback
538
+ run_context = { session_id: @session_id, run_id: task_id.to_s,
539
+ source: @source.to_s, input_created_at: created_at,
540
+ observed_at: Time.now.utc.iso8601(6), status: "running" }
523
541
 
524
542
  # Continuation of a previously-interrupted task (e.g. user sent a
525
543
  # supplementary message without stopping the running task) keeps the
@@ -589,13 +607,15 @@ module Clacky
589
607
  @input_mutex.synchronize { @accepting_steering = true }
590
608
  notify_input_queue
591
609
  # The task and user history already exist. A terminal verdict skips the
592
- # loop and completion hooks; ensure still cleans up this started turn.
593
- hook_result = @hooks.trigger(:on_start, user_input)
610
+ # loop; ensure still reports completion and cleans up this started turn.
611
+ hook_result = @hooks.trigger(:on_start, user_input, run_context)
594
612
  case hook_result[:action]
595
613
  when :deny
614
+ run_context[:reason] = "hook_denied"
596
615
  @ui&.show_warning(hook_result[:reason] || "Task denied by hook")
597
616
  return result = build_result.merge(queue_paused: true)
598
617
  when :handled
618
+ run_context[:handled] = true
599
619
  return result = hook_result[:result]
600
620
  end
601
621
 
@@ -793,6 +813,7 @@ module Clacky
793
813
  notify_input_queue
794
814
  result = build_result(awaiting_user_feedback: awaiting_user_feedback)
795
815
  result[:queue_paused] = true if awaiting_user_feedback || task_interrupted
816
+ run_context[:reason] = "tool_denied" if task_interrupted
796
817
 
797
818
  # Run skill evolution hooks after main loop completes
798
819
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
@@ -825,7 +846,8 @@ module Clacky
825
846
  awaiting_user_feedback: awaiting_user_feedback
826
847
  )
827
848
  end
828
- @hooks.trigger(:on_complete, result)
849
+ completion_sent = true
850
+ complete_run_hook(result, run_context)
829
851
 
830
852
  # Standing-goal loop: after a completed turn, ask the judge whether the
831
853
  # goal is met. If not (and budget/health allow), auto-run the next turn
@@ -840,6 +862,8 @@ module Clacky
840
862
  result[:queue_paused] = true if @goal_manager&.state&.paused?
841
863
  result
842
864
  rescue Clacky::AgentInterrupted
865
+ result = { status: :cancelled }
866
+ run_context[:reason] = (Thread.current[:interrupt_reason] || :user).to_s if run_context
843
867
  # A cancelled fan-out captured its subagents' progress but never reached
844
868
  # observe() to persist it — anchor those trails now so a page reload
845
869
  # after the interrupt still shows what the subagents did.
@@ -852,6 +876,7 @@ module Clacky
852
876
  # Let CLI handle the interrupt message
853
877
  raise
854
878
  rescue StandardError => e
879
+ run_context[:error_code] = e.error_code if run_context && e.respond_to?(:error_code)
855
880
  # Log complete error information to debug_logs for troubleshooting
856
881
  @debug_logs << {
857
882
  timestamp: Time.now.iso8601,
@@ -871,6 +896,11 @@ module Clacky
871
896
  result = build_result(:error, error: e.message)
872
897
  raise
873
898
  ensure
899
+ # A recursive goal continuation owns its own completion. Do not let an
900
+ # unwinding predecessor settle a newer turn or report the same turn twice.
901
+ if run_context && !completion_sent && task_id == @current_task_id
902
+ complete_run_hook(result, run_context)
903
+ end
874
904
  if run_turn_started && task_id == @current_task_id
875
905
  @input_mutex.synchronize do
876
906
  @accepting_steering = false
@@ -888,10 +918,56 @@ module Clacky
888
918
  Clacky::Telemetry.task!(result: result) if run_turn_started
889
919
  end
890
920
 
921
+ private def complete_run_hook(result, context)
922
+ result = { status: :error } unless result.is_a?(Hash)
923
+ cancelled = context[:reason] || [:cancelled, :interrupted].include?(result[:status])
924
+ status = if cancelled
925
+ "cancelled"
926
+ elsif result[:status] != :success
927
+ "failed"
928
+ elsif result[:awaiting_user_feedback]
929
+ "awaiting_user"
930
+ else
931
+ "completed"
932
+ end
933
+ # Denied work can retain its historical return value for queue control,
934
+ # but completion observers must never interpret it as a successful run.
935
+ notification = cancelled ? result.merge(status: :cancelled) : result
936
+ @hooks.trigger(:on_complete, notification,
937
+ context.merge(status: status, observed_at: Time.now.utc.iso8601(6)))
938
+ end
939
+
891
940
  # Shared by initial input and steering; only the execution thread writes history.
892
941
  private def append_user_input(user_input, files: nil, reference_contexts: nil,
893
942
  display_text: nil, created_at: nil, references_display: nil,
894
943
  task_id: @current_task_id)
944
+ # Resolve lightweight metadata first, then commit the accepted user turn
945
+ # before any attachment parser/OCR/sidecar work can block or fail. Session
946
+ # switching and graceful shutdown both read from @history, so the turn
947
+ # must already be authoritative while those slow operations are running.
948
+ skill_command = parse_skill_command(user_input)
949
+ skill_command_display = if skill_command[:found] && skill_command[:skill]
950
+ skill_command[:skill].display_name(Thread.current[:lang])
951
+ end
952
+ created_at ||= Time.now.to_f
953
+ message_id = SecureRandom.uuid
954
+ staged = Array(files).any?
955
+ staged_display_files = display_files_for(files)
956
+ message_metadata = {
957
+ role: "user", message_id: message_id, task_id: task_id, created_at: created_at,
958
+ display_text: display_text,
959
+ skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
960
+ skill_command_display: skill_command_display,
961
+ display_references: Array(references_display).empty? ? nil : references_display
962
+ }
963
+ if staged
964
+ @history.append(message_metadata.merge(
965
+ content: user_input,
966
+ display_files: staged_display_files.empty? ? nil : staged_display_files
967
+ ))
968
+ staged_message_id = message_id
969
+ end
970
+
895
971
  # Split files into vision images and disk files; downgrade oversized images to disk
896
972
  image_files, disk_files = partition_files(Array(files))
897
973
  vision_images, downgraded = resolve_vision_images(image_files)
@@ -963,37 +1039,21 @@ module Clacky
963
1039
  # stored here — they recover from image_url blocks in user_content. Downgraded
964
1040
  # images (provider has no vision / too large / OCR'd) DO need path here so the
965
1041
  # UI can re-render them from the on-disk copy across session switches.
966
- display_files = all_disk_files.filter_map do |f|
967
- # @mention file/directory references are replayed from display_references
968
- # (with mention badges), so skip them here to avoid double-rendering as
969
- # plain attachment badges.
970
- next if f[:reference] || f["reference"]
971
- name = f[:name] || f["name"]
972
- next unless name
973
- { name: name, type: f[:type] || f["type"] || "file",
974
- path: f[:path] || f["path"],
975
- preview_path: f[:preview_path] || f["preview_path"] }
1042
+ display_files = display_files_for(all_disk_files)
1043
+ resolved_message = message_metadata.merge(
1044
+ content: user_content,
1045
+ display_files: display_files.empty? ? nil : display_files
1046
+ )
1047
+ if staged
1048
+ replaced = @history.replace_user_message(
1049
+ message_id: message_id,
1050
+ message: resolved_message
1051
+ )
1052
+ raise AgentError, "Accepted user message disappeared during attachment processing" unless replaced
1053
+ else
1054
+ @history.append(resolved_message)
976
1055
  end
977
1056
 
978
- # Resolved once here (not after append) so the user message can carry the
979
- # confirmed skill name: only a skill that actually dispatches gets marked,
980
- # so the UI never highlights a typo'd or unavailable command. The display
981
- # name is resolved against the client's language (Thread.current[:lang],
982
- # seeded from the WS message / X-Lang header) so the Web UI and third-party
983
- # clients can render a localized label without re-resolving the skill.
984
- skill_command = parse_skill_command(user_input)
985
- skill_command_display = if skill_command[:found] && skill_command[:skill]
986
- skill_command[:skill].display_name(Thread.current[:lang])
987
- end
988
-
989
- created_at ||= Time.now.to_f
990
- @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
991
- display_text: display_text,
992
- skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
993
- skill_command_display: skill_command_display,
994
- display_files: display_files.empty? ? nil : display_files,
995
- display_references: Array(references_display).empty? ? nil : references_display })
996
-
997
1057
  # Inject disk file references as a system_injected message so:
998
1058
  # - LLM sees the file info (system_injected is NOT stripped from to_api)
999
1059
  # - replay_history skips it (next if ev[:system_injected]), keeping the user bubble clean
@@ -1108,6 +1168,36 @@ module Clacky
1108
1168
  # skill-declared model not found) still stops the progress spinner.
1109
1169
  inject_skill_command_as_assistant_message(skill_command, task_id)
1110
1170
 
1171
+ rescue Clacky::AgentInterrupted, StandardError
1172
+ # The staged turn exists only to make an accepted upload replayable while
1173
+ # attachment work is in progress. If that work crashes, remove this turn
1174
+ # and anything injected after it so the next request cannot inherit a
1175
+ # half-resolved attachment context. Parser failures returned as FileRef
1176
+ # metadata do not raise and therefore remain visible/actionable.
1177
+ @history.truncate_from_message(staged_message_id) if staged_message_id
1178
+ raise
1179
+ end
1180
+
1181
+ # Build replay-only attachment metadata without parsing file contents.
1182
+ # @mention references render through display_references and must not also
1183
+ # appear as plain attachment badges.
1184
+ private def display_files_for(files)
1185
+ Array(files).filter_map do |file|
1186
+ next if file[:reference] || file["reference"]
1187
+
1188
+ name = file[:name] || file["name"]
1189
+ next unless name
1190
+
1191
+ mime_type = file[:mime_type] || file["mime_type"]
1192
+ type = file[:type] || file["type"]
1193
+ type ||= "image" if mime_type.to_s.start_with?("image/")
1194
+ {
1195
+ name: name,
1196
+ type: type || "file",
1197
+ path: file[:path] || file["path"],
1198
+ preview_path: file[:preview_path] || file["preview_path"]
1199
+ }
1200
+ end
1111
1201
  end
1112
1202
 
1113
1203
  def enqueue_input(content, delivery: :queue, **options)
@@ -1240,6 +1330,7 @@ module Clacky
1240
1330
  end
1241
1331
  entries.each do |entry|
1242
1332
  options = entry[:options].dup
1333
+ options.delete(:feedback)
1243
1334
  source = options.delete(:source) || :web
1244
1335
  history_size = @history.size
1245
1336
  begin
@@ -1655,9 +1746,20 @@ module Clacky
1655
1746
  # can extract the questions and emit a "request_feedback" event
1656
1747
  # (renders as a clickable card in the browser).
1657
1748
  # Fallback UIs (terminal, IM channels) receive the formatted text message.
1658
- @ui&.show_tool_call(call[:name], call[:arguments])
1659
-
1660
- if @config.permission_mode == :auto_approve
1749
+ if @feedback && @ui.respond_to?(:wait_for_feedback)
1750
+ questions = Tools::AskUser.normalize_questions(args)
1751
+ question = questions.first
1752
+ label = if questions.one? && !question[:multi] && question[:recommended]
1753
+ question[:options][question[:recommended]]
1754
+ end
1755
+ answer, feedback_options = @ui.wait_for_feedback(timeout_seconds: @feedback[:timeout_seconds], question: question[:question], recommended_label: label) do
1756
+ @ui.show_tool_call(call[:name], call[:arguments])
1757
+ end
1758
+ check_stale!
1759
+ result[:awaiting_feedback] = awaiting_feedback = !answer
1760
+ enqueue_input("Answer: #{answer}", delivery: :steer, created_at: Time.now.to_f, **(feedback_options || {})) if answer
1761
+ elsif @config.permission_mode == :auto_approve
1762
+ @ui&.show_tool_call(call[:name], call[:arguments])
1661
1763
  # auto_approve means the agent runs unattended by default, but a
1662
1764
  # human MAY be watching the terminal. Show a short interactive
1663
1765
  # countdown: if the user steps in, hand control over and wait for
@@ -1680,6 +1782,7 @@ module Clacky
1680
1782
  end
1681
1783
  else
1682
1784
  # confirm_all / confirm_safes — a human is present, truly wait for user input.
1785
+ @ui&.show_tool_call(call[:name], call[:arguments])
1683
1786
  awaiting_feedback = true
1684
1787
  end
1685
1788
  else
@@ -24,6 +24,7 @@ module Clacky
24
24
  "dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
25
25
  # gpt via bedrock (abs-, OpenAI-compatible endpoint)
26
26
  "abs-gpt-6-astra" => "global.openai.gpt-6-astra",
27
+ "abs-gpt-6.1-sol" => "global.openai.gpt-6.1-sol",
27
28
  "abs-gpt-6-sol" => "global.openai.gpt-6-sol",
28
29
  "abs-gpt-6-luna" => "global.openai.gpt-6-luna",
29
30
  "abs-gpt-5.6-sol" => "global.openai.gpt-5.6-sol",
@@ -37,11 +38,20 @@ module Clacky
37
38
  "abs-claude-opus-4-8" => "global.anthropic.claude-opus-4-8",
38
39
  "abs-claude-opus-4-7" => "global.anthropic.claude-opus-4-7",
39
40
  "abs-claude-opus-4-6" => "global.anthropic.claude-opus-4-6",
41
+ "abs-claude-sonnet-5-5" => "global.anthropic.claude-sonnet-5-5",
40
42
  "abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
41
43
  "abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
42
44
  "abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
45
+ "abs-claude-haiku-5-5" => "global.anthropic.claude-haiku-5-5",
43
46
  "abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
47
+ # GLM / Kimi / Grok via TokHub (oc-). The gateway books these under the
48
+ # alias itself, so the mapping is an identity — it exists so the billing UI
49
+ # can resolve the alias instead of dropping the record as unknown.
50
+ "oc-glm-5.3" => "oc-glm-5.3",
51
+ "oc-kimi-k3" => "oc-kimi-k3",
52
+ "oc-grok-4.7" => "oc-grok-4.7",
44
53
  # gemini chat (or-)
54
+ "or-gemini-4-argon" => "gemini-4-argon",
45
55
  "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
46
56
  "or-gemini-3-8-flash" => "gemini-3.8-flash",
47
57
  "or-gemini-3-7-flash" => "gemini-3.7-flash",
data/lib/clacky/cli.rb CHANGED
@@ -481,8 +481,8 @@ module Clacky
481
481
  private def auto_name_session(agent, input)
482
482
  return unless agent.history.empty?
483
483
 
484
- auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 30]
485
- auto_name += "…" if input.to_s.strip.length > 30
484
+ auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 80]
485
+ auto_name += "…" if input.to_s.strip.length > 80
486
486
  agent.rename(auto_name)
487
487
  end
488
488
 
data/lib/clacky/client.rb CHANGED
@@ -56,6 +56,13 @@ module Clacky
56
56
  @read_timeout = read_timeout
57
57
  end
58
58
 
59
+ # Console URL where the user can inspect credit / API key state. nil when
60
+ # the provider has no preset (custom base_url).
61
+ def account_url
62
+ preset = Providers.get(@provider_id)
63
+ preset && preset["website_url"]
64
+ end
65
+
59
66
  # Returns true when the client is using the AWS Bedrock Converse API.
60
67
  def bedrock?
61
68
  @use_bedrock
@@ -127,7 +127,7 @@ module Clacky
127
127
  # rounds with no tool calls at all (e.g. the very first "hi"). The
128
128
  # pending event is emitted synchronously so the UI can show a "working"
129
129
  # state immediately instead of the card popping in out of nowhere.
130
- def finish_run
130
+ def finish_run(analyze: true)
131
131
  snapshot = @mutex.synchronize do
132
132
  snap = {
133
133
  tools: @tools_this_run,
@@ -139,6 +139,7 @@ module Clacky
139
139
  @tools_this_run = 0
140
140
  snap
141
141
  end
142
+ return unless analyze
142
143
  Clacky::Logger.info("[Advisor] finish_run",
143
144
  session: @agent.session_id.to_s,
144
145
  tools: snapshot[:tools],
@@ -4,8 +4,9 @@ require_relative "advisor"
4
4
 
5
5
  # on_complete: a run has ended — reset the per-run analysis budget so the
6
6
  # next run starts fresh.
7
- Clacky::ExtensionHookRegistry.add do |_result, agent|
7
+ Clacky::ExtensionHookRegistry.add do |result, agent, context|
8
8
  next unless Clacky::Advisor.enabled_for?(agent)
9
9
 
10
- Clacky::Advisor.worker_for(agent).finish_run
10
+ successful = result.is_a?(Hash) && result[:status] == :success && !context&.dig(:handled)
11
+ Clacky::Advisor.worker_for(agent).finish_run(analyze: successful)
11
12
  end
@@ -112,6 +112,12 @@
112
112
  "pub.screenshot.err": "Upload failed: {{msg}}",
113
113
  "pub.screenshot.err.type": "Only PNG / JPG / GIF are allowed",
114
114
  "pub.screenshot.err.size": "File size must be under 5 MB",
115
+ "pub.screenshot.err.unbound": "this device isn't linked to a platform account",
116
+ "pub.screenshot.err.missingFile": "no file received",
117
+ "pub.screenshot.err.extId": "missing extension ID",
118
+ "pub.screenshot.err.service": "the upload service is unavailable, please try again later",
119
+ "pub.screenshot.err.generic": "Upload failed, please try again",
120
+ "pub.screenshot.bind": "Authorize",
115
121
  "pub.readme.edit.title": "Edit README",
116
122
  "readme.pane.editor": "Markdown editor",
117
123
  "readme.pane.preview": "Preview",
@@ -318,6 +324,12 @@
318
324
  "pub.screenshot.err": "上传失败:{{msg}}",
319
325
  "pub.screenshot.err.type": "仅支持 PNG / JPG / GIF 格式",
320
326
  "pub.screenshot.err.size": "文件大小不能超过 5 MB",
327
+ "pub.screenshot.err.unbound": "设备未绑定平台账号",
328
+ "pub.screenshot.err.missingFile": "未收到文件",
329
+ "pub.screenshot.err.extId": "缺少扩展 ID",
330
+ "pub.screenshot.err.service": "上传服务暂不可用,请稍后重试",
331
+ "pub.screenshot.err.generic": "上传失败,请重试",
332
+ "pub.screenshot.bind": "去授权",
321
333
  "pub.readme.edit.title": "编辑使用说明",
322
334
  "readme.pane.editor": "Markdown 编写",
323
335
  "readme.pane.preview": "预览",
@@ -651,15 +663,17 @@
651
663
 
652
664
  const status = el("p", { class: "studio-modal-status", text: t("bind.body") });
653
665
  const codeLine = el("p", { class: "studio-modal-code" });
654
- const link = el("a", { class: "studio-modal-link", target: "_blank", rel: "noopener", text: t("bind.openLink") });
666
+ const link = el("a", { class: "btn-primary", target: "_blank", rel: "noopener", text: t("bind.openLink") });
655
667
  link.style.display = "none";
656
668
 
657
669
  const modal = openModal({
658
670
  title: t("bind.title"),
659
- body: el("div", null, [status, codeLine, link]),
671
+ body: el("div", null, [status, codeLine]),
660
672
  onClose: () => { polling = false; resolve(false); },
661
673
  buttons: [{ label: t("bind.cancel"), keepOpen: false, onClick: () => { polling = false; resolve(false); } }],
662
674
  });
675
+ // A real link (not a button) so it still opens when the browser blocks our popup.
676
+ modal.footer.appendChild(link);
663
677
 
664
678
  function finish(ok) { polling = false; modal.close(); resolve(ok); }
665
679
 
@@ -1342,21 +1356,38 @@
1342
1356
 
1343
1357
  const screenshotErr = el("p", { class: "studio-modal-status studio-modal-status-error", style: "display:none;margin-top:6px" });
1344
1358
  screenshotsSection.appendChild(screenshotErr);
1345
- fileInput.addEventListener("change", async () => {
1346
- const file = fileInput.files[0];
1347
- if (!file) return;
1348
- fileInput.value = "";
1349
- screenshotErr.style.display = "none";
1350
- if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
1351
- screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") });
1352
- screenshotErr.style.display = "";
1353
- return;
1354
- }
1355
- if (file.size > 5 * 1024 * 1024) {
1356
- screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") });
1357
- screenshotErr.style.display = "";
1358
- return;
1359
+
1360
+ // Shows a localized error; passing retryFile appends an "Authorize" link that
1361
+ // reopens device binding and re-uploads that file once bound.
1362
+ function showScreenshotError(text, retryFile) {
1363
+ screenshotErr.textContent = text;
1364
+ if (retryFile) {
1365
+ screenshotErr.appendChild(document.createTextNode(" "));
1366
+ screenshotErr.appendChild(el("button", {
1367
+ type: "button",
1368
+ class: "studio-link-btn",
1369
+ text: t("pub.screenshot.bind"),
1370
+ onclick: () => bindThenUploadScreenshot(retryFile),
1371
+ }));
1359
1372
  }
1373
+ screenshotErr.style.display = "";
1374
+ }
1375
+
1376
+ function screenshotErrorText(e) {
1377
+ if (e.status === 422 && e.message === "missing file") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.missingFile") });
1378
+ if (e.status === 422 && e.message === "ext_id required") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.extId") });
1379
+ if (e.status === 502) return t("pub.screenshot.err", { msg: t("pub.screenshot.err.service") });
1380
+ return t("pub.screenshot.err.generic");
1381
+ }
1382
+
1383
+ async function bindThenUploadScreenshot(file) {
1384
+ screenshotErr.style.display = "none";
1385
+ if (await runBindingFlow()) return uploadScreenshot(file);
1386
+ showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.unbound") }), file);
1387
+ }
1388
+
1389
+ async function uploadScreenshot(file) {
1390
+ let needsBinding = false;
1360
1391
  uploadBtn.disabled = true;
1361
1392
  uploadBtn.textContent = t("pub.screenshot.uploading");
1362
1393
  screenshotErr.style.display = "none";
@@ -1366,7 +1397,12 @@
1366
1397
  form.append("file", file);
1367
1398
  const res = await fetch("/api/ext/ext-studio/screenshot", { method: "POST", body: form });
1368
1399
  const json = await res.json();
1369
- if (!res.ok || !json.ok) throw new Error(json.error || "upload failed");
1400
+ if (!res.ok || !json.ok) {
1401
+ const err = new Error(json.error || `Request failed (${res.status})`);
1402
+ err.status = res.status;
1403
+ err.data = json;
1404
+ throw err;
1405
+ }
1370
1406
  // Insert ![](url) at cursor position in the editor.
1371
1407
  // Ensure the image tag is on its own line by adding newlines if needed.
1372
1408
  const mdImg = `![](${json.url})`;
@@ -1382,12 +1418,33 @@
1382
1418
  readmeArea.focus();
1383
1419
  readmeArea.dispatchEvent(new Event("input"));
1384
1420
  } catch (e) {
1385
- screenshotErr.textContent = t("pub.screenshot.err", { msg: e.message });
1386
- screenshotErr.style.display = "";
1421
+ if (e.status === 428 && e.data && e.data.needs_binding) {
1422
+ needsBinding = true;
1423
+ } else {
1424
+ console.error("[ext-studio] screenshot upload failed:", e);
1425
+ showScreenshotError(screenshotErrorText(e));
1426
+ }
1387
1427
  } finally {
1388
1428
  uploadBtn.disabled = false;
1389
1429
  uploadBtn.textContent = t("pub.screenshot.upload");
1390
1430
  }
1431
+ if (needsBinding) await bindThenUploadScreenshot(file);
1432
+ }
1433
+
1434
+ fileInput.addEventListener("change", () => {
1435
+ const file = fileInput.files[0];
1436
+ if (!file) return;
1437
+ fileInput.value = "";
1438
+ screenshotErr.style.display = "none";
1439
+ if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
1440
+ showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") }));
1441
+ return;
1442
+ }
1443
+ if (file.size > 5 * 1024 * 1024) {
1444
+ showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") }));
1445
+ return;
1446
+ }
1447
+ uploadScreenshot(file);
1391
1448
  });
1392
1449
 
1393
1450
  box.appendChild(screenshotsSection);
@@ -2312,7 +2369,7 @@
2312
2369
  .studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
2313
2370
  .studio-input-error { border-color: var(--color-error) !important; }
2314
2371
  .studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
2315
- .studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
2372
+ .studio-modal .modal-footer a.btn-primary { display: inline-flex; align-items: center; font-size: 14px; text-decoration: none; }
2316
2373
  .studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
2317
2374
  .studio-section { margin-bottom: 20px; }
2318
2375
  .studio-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
@@ -392,6 +392,7 @@
392
392
  });
393
393
  if (res.ok) {
394
394
  state.footEl.textContent = res.message || t("tm.restored", "已恢复");
395
+ if (Clacky.Workspace) Clacky.Workspace.notifyTaskCompleted(state.sessionId);
395
396
  state.expanded = null;
396
397
  closeDetail(state);
397
398
  await loadHistory(state);