openclacky 1.1.5 → 1.2.0

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +113 -63
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +521 -17
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +30 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +41 -6
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +51 -40
  52. data/lib/clacky/web/i18n.js +248 -24
  53. data/lib/clacky/web/index.html +362 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +299 -18
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +15 -2
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require_relative "billing_record"
7
+
8
+ module Clacky
9
+ module Billing
10
+ # Persistent storage for billing records using JSONL files
11
+ # Records are stored in monthly files: ~/.clacky/billing/YYYY-MM.jsonl
12
+ class BillingStore
13
+ BILLING_DIR = File.join(Dir.home, ".clacky", "billing")
14
+
15
+ def initialize(billing_dir: nil)
16
+ @billing_dir = billing_dir || BILLING_DIR
17
+ ensure_billing_dir
18
+ end
19
+
20
+ # Append a billing record to the current month's file
21
+ # @param record [BillingRecord] The record to append
22
+ # @return [String] The record ID
23
+ def append(record)
24
+ record.id ||= SecureRandom.uuid
25
+ record.timestamp ||= Time.now
26
+
27
+ month_file = current_month_file
28
+ File.open(month_file, "a") do |f|
29
+ f.puts(JSON.generate(record.to_h))
30
+ end
31
+ FileUtils.chmod(0o600, month_file)
32
+
33
+ record.id
34
+ end
35
+
36
+ # Query billing records with optional filters
37
+ # @param from [Time, nil] Start time (inclusive)
38
+ # @param to [Time, nil] End time (inclusive)
39
+ # @param model [String, nil] Filter by model name
40
+ # @param session_id [String, nil] Filter by session ID
41
+ # @param limit [Integer, nil] Maximum number of records to return
42
+ # @return [Array<BillingRecord>] Matching records, newest first
43
+ def query(from: nil, to: nil, model: nil, session_id: nil, limit: nil)
44
+ records = []
45
+
46
+ billing_files.each do |file|
47
+ File.foreach(file) do |line|
48
+ next if line.strip.empty?
49
+
50
+ begin
51
+ hash = JSON.parse(line, symbolize_names: true)
52
+ record = BillingRecord.from_h(hash)
53
+
54
+ # Apply filters
55
+ next if from && record.timestamp < from
56
+ next if to && record.timestamp > to
57
+ next if model && record.model != model
58
+ next if session_id && record.session_id != session_id
59
+
60
+ records << record
61
+ rescue JSON::ParserError
62
+ # Skip malformed lines
63
+ next
64
+ end
65
+ end
66
+ end
67
+
68
+ # Sort by timestamp descending (newest first)
69
+ records.sort_by! { |r| r.timestamp }.reverse!
70
+
71
+ # Apply limit
72
+ limit ? records.first(limit) : records
73
+ end
74
+
75
+ # Get summary statistics for a time period
76
+ # @param period [Symbol] :day, :week, :month, :year, or :all
77
+ # @return [Hash] Summary with total_cost, total_tokens, by_model, etc.
78
+ def summary(period: :month)
79
+ from_time = period_start(period)
80
+ records = query(from: from_time)
81
+
82
+ total_cost = records.sum { |r| r.cost_usd || 0 }
83
+ total_prompt = records.sum { |r| r.prompt_tokens || 0 }
84
+ total_completion = records.sum { |r| r.completion_tokens || 0 }
85
+ total_cache_read = records.sum { |r| r.cache_read_tokens || 0 }
86
+ total_cache_write = records.sum { |r| r.cache_write_tokens || 0 }
87
+
88
+ by_model = records.group_by(&:model).transform_values do |rs|
89
+ {
90
+ cost: rs.sum { |r| r.cost_usd || 0 },
91
+ prompt_tokens: rs.sum { |r| r.prompt_tokens || 0 },
92
+ completion_tokens: rs.sum { |r| r.completion_tokens || 0 },
93
+ requests: rs.size
94
+ }
95
+ end
96
+
97
+ by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }.transform_values do |rs|
98
+ rs.sum { |r| r.cost_usd || 0 }
99
+ end
100
+
101
+ {
102
+ period: period,
103
+ from: from_time&.iso8601,
104
+ to: Time.now.iso8601,
105
+ total_cost: total_cost.round(6),
106
+ total_tokens: total_prompt + total_completion,
107
+ prompt_tokens: total_prompt,
108
+ completion_tokens: total_completion,
109
+ cache_read_tokens: total_cache_read,
110
+ cache_write_tokens: total_cache_write,
111
+ by_model: by_model,
112
+ by_day: by_day,
113
+ record_count: records.size
114
+ }
115
+ end
116
+
117
+ # Get daily cost breakdown for the last N days
118
+ # @param days [Integer] Number of days to include
119
+ # @return [Array<Hash>] Daily summaries with date and cost
120
+ def daily_breakdown(days: 30)
121
+ from_time = Time.now - (days * 24 * 60 * 60)
122
+ records = query(from: from_time)
123
+
124
+ by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }
125
+
126
+ (0...days).map do |i|
127
+ date = (Time.now - (i * 24 * 60 * 60)).strftime("%Y-%m-%d")
128
+ day_records = by_day[date] || []
129
+ {
130
+ date: date,
131
+ cost: day_records.sum { |r| r.cost_usd || 0 }.round(6),
132
+ tokens: day_records.sum { |r| r.total_tokens },
133
+ prompt_tokens: day_records.sum { |r| r.prompt_tokens || 0 },
134
+ completion_tokens: day_records.sum { |r| r.completion_tokens || 0 },
135
+ cache_read_tokens: day_records.sum { |r| r.cache_read_tokens || 0 },
136
+ cache_write_tokens: day_records.sum { |r| r.cache_write_tokens || 0 },
137
+ requests: day_records.size
138
+ }
139
+ end.reverse
140
+ end
141
+
142
+ # Delete old billing records
143
+ # @param before [Time] Delete records before this time
144
+ # @return [Integer] Number of files deleted
145
+ def cleanup(before:)
146
+ deleted = 0
147
+ billing_files.each do |file|
148
+ # Parse month from filename (YYYY-MM.jsonl)
149
+ basename = File.basename(file, ".jsonl")
150
+ file_month = Time.parse("#{basename}-01") rescue nil
151
+ next unless file_month
152
+
153
+ # Delete if the entire month is before the cutoff
154
+ if file_month < before - (31 * 24 * 60 * 60)
155
+ File.delete(file)
156
+ deleted += 1
157
+ end
158
+ end
159
+ deleted
160
+ end
161
+
162
+ private def ensure_billing_dir
163
+ FileUtils.mkdir_p(@billing_dir) unless Dir.exist?(@billing_dir)
164
+ end
165
+
166
+ private def current_month_file
167
+ File.join(@billing_dir, "#{Time.now.strftime('%Y-%m')}.jsonl")
168
+ end
169
+
170
+ private def billing_files
171
+ Dir.glob(File.join(@billing_dir, "*.jsonl")).sort.reverse
172
+ end
173
+
174
+ private def period_start(period)
175
+ now = Time.now
176
+ case period
177
+ when :day
178
+ Time.new(now.year, now.month, now.day)
179
+ when :week
180
+ now - (7 * 24 * 60 * 60)
181
+ when :month
182
+ Time.new(now.year, now.month, 1)
183
+ when :year
184
+ Time.new(now.year, 1, 1)
185
+ when :all
186
+ nil
187
+ else
188
+ Time.new(now.year, now.month, 1)
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
data/lib/clacky/cli.rb CHANGED
@@ -101,7 +101,7 @@ module Clacky
101
101
  end
102
102
 
103
103
  # Validate and get working directory
104
- working_dir = validate_working_directory(options[:path])
104
+ working_dir = validate_working_directory(options[:path], agent_config)
105
105
 
106
106
  # Update agent config with CLI options
107
107
  agent_config.permission_mode = options[:mode].to_sym if options[:mode]
@@ -338,25 +338,16 @@ module Clacky
338
338
 
339
339
  # ── Brand license check (CLI mode) ──────────────────────────────────────
340
340
  #
341
- # Called at the start of run_agent_with_ui2, before UI2 raw mode begins.
342
- # Uses Thor's say + tty-prompt for interaction (both are existing dependencies).
343
- #
344
- # Flow:
345
- # not branded -> skip (standard OpenClacky experience)
346
- # branded, no key -> prompt for license key and activate
347
- # branded, expired -> warn and continue
348
- # branded, active -> send heartbeat if interval elapsed (once per day)
341
+ # CLI is a developer-oriented entrypoint: we never block startup with an
342
+ # interactive license prompt. Unactivated installs run in free mode; the
343
+ # WebUI is where end-users activate. This method only surfaces non-blocking
344
+ # warnings (expiry, offline grace period) and dispatches async heartbeats.
349
345
  private def check_brand_license_cli
350
346
  brand = Clacky::BrandConfig.load
351
347
  return unless brand.branded?
348
+ return unless brand.activated?
352
349
 
353
- Clacky::Logger.info("[Brand] check_brand_license_cli: activated=#{brand.activated?} expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
354
-
355
- unless brand.activated?
356
- Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
357
- cli_prompt_license_activation(brand)
358
- return
359
- end
350
+ Clacky::Logger.info("[Brand] check_brand_license_cli: activated=true expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
360
351
 
361
352
  if brand.expired?
362
353
  Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
@@ -366,11 +357,6 @@ module Clacky
366
357
  return
367
358
  end
368
359
 
369
- # Heartbeat is fire-and-forget — startup must never block on the
370
- # license server. The grace_period_exceeded? check below now keys off
371
- # license_last_heartbeat_failure (set on a failed heartbeat, cleared
372
- # on success), so a user who simply hasn't run the app for >3 days
373
- # no longer sees a false "offline" warning on first launch.
374
360
  if brand.heartbeat_due?
375
361
  Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
376
362
  Thread.new do
@@ -397,46 +383,16 @@ module Clacky
397
383
  end
398
384
  end
399
385
 
400
- # Interactive license key prompt using tty-prompt.
401
- private def cli_prompt_license_activation(brand)
402
- prompt = TTY::Prompt.new
403
-
404
- say ""
405
- say "Welcome to #{brand.product_name}!", :cyan
406
- say "A license key is required to activate this installation."
407
- say ""
408
-
409
- loop do
410
- key = prompt.ask("Enter your license key (XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX):",
411
- required: false) { |q| q.modify :strip }
412
-
413
- if key.nil? || key.empty?
414
- say "No key entered. You can activate later by re-launching.", :yellow
415
- say ""
416
- return
417
- end
418
-
419
- unless key.match?(/\A[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}\z/)
420
- say "Invalid key format. Expected: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX", :red
421
- next
422
- end
423
-
424
- say "Activating..."
425
- result = brand.activate!(key)
386
+ CLI_DEFAULT_SESSION_NAME = "CLI Session"
426
387
 
427
- if result[:success]
428
- say result[:message], :green
429
- say ""
430
- return
431
- else
432
- say result[:message], :red
433
- say "(Press Enter to skip activation.)"
434
- end
435
- end
388
+ # Format a number with thousand separators for display
389
+ # @param num [Integer, Float] The number to format
390
+ # @return [String] Formatted number string
391
+ private def format_number(num)
392
+ return "0" if num.nil? || num == 0
393
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
436
394
  end
437
395
 
438
- CLI_DEFAULT_SESSION_NAME = "CLI Session"
439
-
440
396
  # Auto-name a CLI session from the first user message, mirroring server-side logic.
441
397
  # Renames when the agent has no history yet (i.e. first message of the session).
442
398
  private def auto_name_session(agent, input)
@@ -447,12 +403,14 @@ module Clacky
447
403
  agent.rename(auto_name)
448
404
  end
449
405
 
450
- def validate_working_directory(path)
406
+ def validate_working_directory(path, config = nil)
451
407
  working_dir = path || Dir.pwd
452
408
 
453
- # If no path specified and currently in home directory, use ~/clacky_workspace
409
+ # If no path specified and currently in home directory, use configured
410
+ # default_working_dir (or ~/clacky_workspace as fallback)
454
411
  if path.nil? && File.expand_path(working_dir) == File.expand_path(Dir.home)
455
- working_dir = File.expand_path("~/clacky_workspace")
412
+ default = config&.default_working_dir || File.expand_path("~/clacky_workspace")
413
+ working_dir = File.expand_path(default)
456
414
 
457
415
  # Create directory if it doesn't exist
458
416
  unless Dir.exist?(working_dir)
@@ -931,6 +889,98 @@ module Clacky
931
889
 
932
890
  end
933
891
 
892
+ # ── billing command ────────────────────────────────────────────────────────
893
+ desc "billing", "Show billing summary and usage statistics"
894
+ long_desc <<-LONGDESC
895
+ Display billing summary with token usage and cost breakdown.
896
+
897
+ Period options:
898
+ day - Today's usage
899
+ week - Last 7 days
900
+ month - Current month (default)
901
+ year - Current year
902
+ all - All time
903
+
904
+ Examples:
905
+ $ clacky billing
906
+ $ clacky billing --period week
907
+ $ clacky billing --period all --json
908
+ LONGDESC
909
+ option :period, type: :string, default: "month",
910
+ desc: "Time period: day, week, month, year, all (default: month)"
911
+ option :json, type: :boolean, default: false,
912
+ desc: "Output as JSON"
913
+ option :days, type: :numeric, default: 30,
914
+ desc: "Number of days for daily breakdown (default: 30)"
915
+ option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
916
+ def billing
917
+ if options[:help]
918
+ invoke :help, ["billing"]
919
+ return
920
+ end
921
+
922
+ require_relative "billing/billing_store"
923
+
924
+ store = Clacky::Billing::BillingStore.new
925
+ period = options[:period].to_sym
926
+ summary = store.summary(period: period)
927
+
928
+ if options[:json]
929
+ require "json"
930
+ puts JSON.pretty_generate(summary)
931
+ return
932
+ end
933
+
934
+ # Display formatted billing summary
935
+ puts ""
936
+ puts "📊 Billing Summary (#{period})"
937
+ puts "─" * 50
938
+ puts ""
939
+
940
+ # Total cost
941
+ cost_str = summary[:total_cost] > 0 ? "$#{format('%.4f', summary[:total_cost])}" : "$0.0000"
942
+ puts " 💰 Total Cost: #{cost_str}"
943
+ puts " 📝 Total Tokens: #{format_number(summary[:total_tokens])}"
944
+ puts " 📥 Prompt Tokens: #{format_number(summary[:prompt_tokens])}"
945
+ puts " 📤 Completion: #{format_number(summary[:completion_tokens])}"
946
+ puts " 🗄️ Cache Read: #{format_number(summary[:cache_read_tokens])}"
947
+ puts " 📝 Cache Write: #{format_number(summary[:cache_write_tokens])}"
948
+ puts " 🔢 API Requests: #{summary[:record_count]}"
949
+ puts ""
950
+
951
+ # By model breakdown
952
+ if summary[:by_model] && !summary[:by_model].empty?
953
+ puts "📈 By Model:"
954
+ puts "─" * 50
955
+ summary[:by_model].each do |model, data|
956
+ cost = data.is_a?(Hash) ? data[:cost] : data
957
+ requests = data.is_a?(Hash) ? data[:requests] : "?"
958
+ puts " #{model}"
959
+ puts " Cost: $#{format('%.4f', cost)} | Requests: #{requests}"
960
+ end
961
+ puts ""
962
+ end
963
+
964
+ # Daily breakdown (last N days)
965
+ daily = store.daily_breakdown(days: [options[:days], 14].min)
966
+ recent_days = daily.select { |d| d[:cost] > 0 }.last(7)
967
+
968
+ if recent_days.any?
969
+ puts "📅 Recent Daily Usage:"
970
+ puts "─" * 50
971
+ recent_days.each do |day|
972
+ bar_len = [(day[:cost] * 100).to_i, 30].min
973
+ bar = "█" * bar_len
974
+ puts " #{day[:date]} $#{format('%.4f', day[:cost])} #{bar}"
975
+ end
976
+ puts ""
977
+ end
978
+
979
+ puts "─" * 50
980
+ puts " Data stored in: ~/.clacky/billing/"
981
+ puts ""
982
+ end
983
+
934
984
  # ── server command ─────────────────────────────────────────────────────────
935
985
  desc "server", "Start the Clacky web UI server"
936
986
  long_desc <<-LONGDESC
@@ -1008,8 +1058,8 @@ module Clacky
1008
1058
  $stdout = Clacky::Server::EPIPESafeIO.new($stdout)
1009
1059
  $stderr = Clacky::Server::EPIPESafeIO.new($stderr)
1010
1060
 
1011
- fd = ENV["CLACKY_INHERIT_FD"].to_i
1012
- master_pid = ENV["CLACKY_MASTER_PID"].to_i
1061
+ fd = ENV["CLACKY_INHERIT_FD"].to_i
1062
+ master_pid = ENV["CLACKY_MASTER_PID"].to_i
1013
1063
  # Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
1014
1064
  # returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
1015
1065
  socket = TCPServer.for_fd(fd)
@@ -19,6 +19,25 @@ allowed-tools:
19
19
 
20
20
  Configure the browser tool for Clacky. Config is stored at `~/.clacky/browser.yml`.
21
21
 
22
+ ## Region-Aware Download Links
23
+
24
+ Whenever you show the user a link to download or upgrade Chrome/Edge, pick the right one for their region instead of always using google.com.
25
+
26
+ Treat the user as **in China** when any of these is true:
27
+ - The user is talking to you in Chinese
28
+ - The system locale is Chinese (`echo $LANG` contains `zh_CN` / `zh_`)
29
+ - A previous run of `install_browser.sh` reported `Region: china` (visible in its output)
30
+ - `curl -s --max-time 3 https://www.google.com -o /dev/null -w "%{http_code}"` returns `000` while baidu.com works
31
+
32
+ Use these links accordingly:
33
+
34
+ | Region | Chrome | Edge |
35
+ |---|---|---|
36
+ | China | https://www.google.cn/chrome/ | https://www.microsoft.com/zh-cn/edge |
37
+ | Global | https://www.google.com/chrome/ | https://www.microsoft.com/edge |
38
+
39
+ When unsure, show **both** lines (label them "China:" and "Global:") so the user can pick.
40
+
22
41
  ## Command Parsing
23
42
 
24
43
  | User says | Subcommand |
@@ -197,9 +216,10 @@ Parse the version number:
197
216
  > ⚠️ Your browser version is v${VERSION}. Version 146+ is recommended for best compatibility.
198
217
  > Continuing anyway...
199
218
  - **version < 144 or "unknown"** → Stop:
200
- > ❌ Browser version v${VERSION} is too old. Please upgrade Chrome or Edge to v146+ from:
201
- > - Chrome: https://www.google.com/chrome/
202
- > - Edge: https://www.microsoft.com/edge
219
+ > ❌ Browser version v${VERSION} is too old. Please upgrade Chrome or Edge to v146+.
220
+ >
221
+ > Use the download link from the **Region-Aware Download Links** section above
222
+ > (pick `China` or `Global` based on the user's region).
203
223
  >
204
224
  > After upgrading, run `/browser-setup` again.
205
225
 
@@ -363,7 +383,9 @@ Connection
363
383
 
364
384
  2. Upgrade your browser:
365
385
  - Chrome v142 is too old (need v146+)
366
- - Download latest version: https://www.google.com/chrome/
386
+ - Pick the download link for the user's region from the
387
+ **Region-Aware Download Links** section at the top of this skill
388
+ (China users → google.cn; others → google.com).
367
389
 
368
390
  After fixing these issues, run `/browser-setup` again to verify.
369
391
  ```