openclacky 0.9.9 → 0.9.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 753e91f9083b9710b7aee989e03983c5652de80a2d940c5d709890176d4a2594
4
- data.tar.gz: 3dc782019ce0ebbbe5db904792fd7e9f9c8549f5bb4aa923ed74d2c827b91fdf
3
+ metadata.gz: 938c620eb9886a37c1e190c5bf2d8c804a6ed86eb496332a26f5f225e832a5ec
4
+ data.tar.gz: c165097e40b1bde5339e9819f7ccdd2a3191ecd24f9233a758a4a2406f5d74db
5
5
  SHA512:
6
- metadata.gz: 0a362f6c8db13f8e10b4a3d0800436980d89204a42018976abaf443b17b2a8d0f02c53fa50e859ee33339336e7d81c75004c0d1d2ccce5862caecb802c7817c6
7
- data.tar.gz: 8785443bccb98f429f6fc77c7aacb8debb47d7a91fc30519d00089c777fd6b1cb83543c059725e4e13d13567ccf65025b949923bd24a278b50049869a9c078d7
6
+ metadata.gz: 845fef0c86bca42adcb00d2fe34946acd3b72b2d750412a744e78a080cef34b653457ae98318ddad5b623f22325b654ad2a77a61d39abc544edbfd11f44891f3
7
+ data.tar.gz: ea770a8ac65695d64affd358b4af0788cccc2c4e70eb95d0afaebea61a639ba8829dbcf61af84d0d8229dac9b4af8e969316a5e5c8fb8ec75f9f430647f9b1cc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.10] - 2026-03-24
11
+
12
+ ### Added
13
+ - **One-click gem upgrade in Web UI**: a new "Upgrade" button in the Web UI lets you update Clacky to the latest version without touching the terminal
14
+ - **WebSocket connection status tips**: the Web UI now shows a clear indicator when the WebSocket connection is lost or reconnecting, so you always know if the server is reachable
15
+ - **Master/worker server architecture**: the server now runs in a master + worker process model, enabling zero-downtime gem upgrades — the master restarts workers seamlessly after an upgrade
16
+
17
+ ### Fixed
18
+ - **Relative paths in write/edit tools**: paths like `./foo/bar.rb` are now correctly resolved relative to the working directory instead of the process root, preventing unexpected file placement
19
+
10
20
  ## [0.9.9] - 2026-03-23
11
21
 
12
22
  ### Added
data/lib/clacky/cli.rb CHANGED
@@ -793,40 +793,71 @@ module Clacky
793
793
  option :brand_test, type: :boolean, default: false,
794
794
  desc: "Enable brand test mode: mock license activation without calling remote API"
795
795
  def server
796
- require_relative "server/http_server"
796
+ if ENV["CLACKY_WORKER"] == "1"
797
+ # ── Worker mode ───────────────────────────────────────────────────────
798
+ # Spawned by Master. Inherit the listen socket from the file descriptor
799
+ # passed via CLACKY_INHERIT_FD, and report back to master via CLACKY_MASTER_PID.
800
+ require_relative "server/http_server"
801
+
802
+ fd = ENV["CLACKY_INHERIT_FD"].to_i
803
+ master_pid = ENV["CLACKY_MASTER_PID"].to_i
804
+ # Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
805
+ # returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
806
+ socket = TCPServer.for_fd(fd)
807
+
808
+ Clacky::Logger.console = true
809
+ Clacky::Logger.info("[cli worker PID=#{Process.pid}] CLACKY_INHERIT_FD=#{fd} CLACKY_MASTER_PID=#{master_pid} socket=#{socket.class} fd=#{socket.fileno}")
810
+
811
+ agent_config = Clacky::AgentConfig.load
812
+ agent_config.permission_mode = :confirm_all
813
+
814
+ client_factory = lambda do
815
+ Clacky::Client.new(
816
+ agent_config.api_key,
817
+ base_url: agent_config.base_url,
818
+ anthropic_format: agent_config.anthropic_format?
819
+ )
820
+ end
797
821
 
798
- agent_config = Clacky::AgentConfig.load
799
- agent_config.permission_mode = :confirm_all
822
+ Clacky::Server::HttpServer.new(
823
+ host: options[:host],
824
+ port: options[:port],
825
+ agent_config: agent_config,
826
+ client_factory: client_factory,
827
+ brand_test: options[:brand_test],
828
+ socket: socket,
829
+ master_pid: master_pid
830
+ ).start
831
+ else
832
+ # ── Master mode ───────────────────────────────────────────────────────
833
+ # First invocation by the user. Start the Master process which holds the
834
+ # socket and supervises worker processes.
835
+ require_relative "server/server_master"
836
+
837
+ if options[:brand_test]
838
+ say "⚡ Brand test mode — license activation uses mock data (no remote API calls).", :yellow
839
+ say ""
840
+ say " Test license keys (paste any into Settings → Brand & License):", :cyan
841
+ say ""
842
+ say " 00000001-FFFFFFFF-DEADBEEF-CAFEBABE-00000001 → Brand1"
843
+ say " 00000002-FFFFFFFF-DEADBEEF-CAFEBABE-00000002 → Brand2"
844
+ say " 00000003-FFFFFFFF-DEADBEEF-CAFEBABE-00000003 → Brand3"
845
+ say ""
846
+ say " To reset: rm ~/.clacky/brand.yml", :cyan
847
+ say ""
848
+ end
800
849
 
801
- if options[:brand_test]
802
- say "⚡ Brand test mode — license activation uses mock data (no remote API calls).", :yellow
803
- say ""
804
- say " Test license keys (paste any into Settings → Brand & License):", :cyan
805
- say ""
806
- say " 00000001-FFFFFFFF-DEADBEEF-CAFEBABE-00000001 → Brand1"
807
- say " 00000002-FFFFFFFF-DEADBEEF-CAFEBABE-00000002 → Brand2"
808
- say " 00000003-FFFFFFFF-DEADBEEF-CAFEBABE-00000003 → Brand3"
809
- say ""
810
- say " To reset: rm ~/.clacky/brand.yml", :cyan
811
- say ""
812
- end
850
+ extra_flags = []
851
+ extra_flags << "--brand-test" if options[:brand_test]
813
852
 
814
- # Factory so each new session gets a fresh Client instance
815
- client_factory = lambda do
816
- Clacky::Client.new(
817
- agent_config.api_key,
818
- base_url: agent_config.base_url,
819
- anthropic_format: agent_config.anthropic_format?
820
- )
821
- end
853
+ Clacky::Logger.console = true
822
854
 
823
- Clacky::Server::HttpServer.new(
824
- host: options[:host],
825
- port: options[:port],
826
- agent_config: agent_config,
827
- client_factory: client_factory,
828
- brand_test: options[:brand_test]
829
- ).start
855
+ Clacky::Server::Master.new(
856
+ host: options[:host],
857
+ port: options[:port],
858
+ extra_flags: extra_flags
859
+ ).run
860
+ end
830
861
  end
831
862
  end
832
863
  end
@@ -132,12 +132,14 @@ module Clacky
132
132
  - 将大目标拆解为可执行的小步骤
133
133
  MD
134
134
 
135
- def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil)
135
+ def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, socket: nil, master_pid: nil)
136
136
  @host = host
137
137
  @port = port
138
138
  @agent_config = agent_config
139
139
  @client_factory = client_factory # callable: -> { Clacky::Client.new(...) }
140
140
  @brand_test = brand_test # when true, skip remote API calls for license activation
141
+ @inherited_socket = socket # TCPServer socket passed from Master (nil = standalone mode)
142
+ @master_pid = master_pid # Master PID so we can send USR1 on upgrade/restart
141
143
  # Capture the absolute path of the entry script and original ARGV at startup,
142
144
  # so api_restart can re-exec the correct binary even if cwd changes later.
143
145
  @restart_script = File.expand_path($0)
@@ -147,7 +149,8 @@ module Clacky
147
149
  session_manager: @session_manager,
148
150
  session_restorer: method(:build_session_from_data)
149
151
  )
150
- @ws_clients = {} # session_id => [WebSocketConnection, ...]
152
+ @ws_clients = {} # session_id => [WebSocketConnection, ...]
153
+ @all_ws_conns = [] # every connected WS client, regardless of session subscription
151
154
  @ws_mutex = Mutex.new
152
155
  # Version cache: { latest: "x.y.z", checked_at: Time }
153
156
  @version_cache = nil
@@ -171,11 +174,16 @@ module Clacky
171
174
  # Enable console logging for the server process so log lines are visible in the terminal.
172
175
  Clacky::Logger.console = true
173
176
 
174
- # Kill any previous server on the same port, then write our own PID file
175
- kill_existing_server(@port)
176
- pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
177
- File.write(pid_file, Process.pid.to_s)
178
- at_exit { File.delete(pid_file) if File.exist?(pid_file) }
177
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
178
+
179
+ # In standalone mode (no master), kill any stale server and manage our own PID file.
180
+ # In worker mode the master owns the PID file; we just skip this block.
181
+ if @inherited_socket.nil?
182
+ kill_existing_server(@port)
183
+ pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
184
+ File.write(pid_file, Process.pid.to_s)
185
+ at_exit { File.delete(pid_file) if File.exist?(pid_file) }
186
+ end
179
187
 
180
188
  # Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
181
189
  # so they can call back into the server without hardcoding the port,
@@ -188,13 +196,43 @@ module Clacky
188
196
  # Override WEBrick's built-in signal traps via StartCallback,
189
197
  # which fires after WEBrick sets its own INT/TERM handlers.
190
198
  # This ensures Ctrl-C always exits immediately.
191
- server = WEBrick::HTTPServer.new(
192
- BindAddress: @host,
193
- Port: @port,
194
- Logger: WEBrick::Log.new(File::NULL),
195
- AccessLog: [],
196
- StartCallback: proc { trap("INT") { exit(0) }; trap("TERM") { exit(0) } }
197
- )
199
+ #
200
+ # When running as a worker under Master, DoNotListen: true prevents WEBrick
201
+ # from calling bind() on its own — we inject the inherited socket instead.
202
+ webrick_opts = {
203
+ BindAddress: @host,
204
+ Port: @port,
205
+ Logger: WEBrick::Log.new(File::NULL),
206
+ AccessLog: [],
207
+ StartCallback: proc { } # signal traps set below, after `server` is created
208
+ }
209
+ webrick_opts[:DoNotListen] = true if @inherited_socket
210
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] WEBrick DoNotListen=#{webrick_opts[:DoNotListen].inspect}")
211
+
212
+ server = WEBrick::HTTPServer.new(**webrick_opts)
213
+
214
+ # Override WEBrick's signal traps now that `server` is available.
215
+ # On INT/TERM: call server.shutdown (graceful), with a 3s hard-kill fallback.
216
+ shutdown_once = false
217
+ shutdown_proc = proc do
218
+ next if shutdown_once
219
+ shutdown_once = true
220
+ Thread.new do
221
+ sleep 1
222
+ Clacky::Logger.warn("[HttpServer] Forced exit after graceful shutdown timeout.")
223
+ exit!(0)
224
+ end
225
+ server.shutdown rescue nil
226
+ end
227
+ trap("INT") { shutdown_proc.call }
228
+ trap("TERM") { shutdown_proc.call }
229
+
230
+ if @inherited_socket
231
+ server.listeners << @inherited_socket
232
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] injected inherited fd=#{@inherited_socket.fileno} listeners=#{server.listeners.map(&:fileno).inspect}")
233
+ else
234
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] standalone, WEBrick listeners=#{server.listeners.map(&:fileno).inspect}")
235
+ end
198
236
 
199
237
  # Mount API + WebSocket handler (takes priority).
200
238
  # Use a custom Servlet so that DELETE/PUT/PATCH requests are not rejected
@@ -238,15 +276,6 @@ module Clacky
238
276
  end
239
277
  end
240
278
 
241
- banner = Clacky::Banner.new
242
- puts ""
243
- puts banner.colored_cli_logo
244
- puts banner.colored_tagline
245
- puts ""
246
- puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
247
- puts " Version: #{Clacky::VERSION}"
248
- puts " Press Ctrl-C to stop."
249
-
250
279
  # Auto-create a default session on startup
251
280
  create_default_session
252
281
 
@@ -390,6 +419,7 @@ module Clacky
390
419
  FileUtils.mkdir_p(working_dir)
391
420
 
392
421
  session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source)
422
+ broadcast_session_update(session_id)
393
423
  json_response(res, 201, { session: @registry.session_summary(session_id) })
394
424
  end
395
425
 
@@ -715,24 +745,35 @@ module Clacky
715
745
 
716
746
  Thread.new do
717
747
  begin
748
+ Clacky::Logger.info("[Upgrade] Starting: gem update openclacky --no-document")
718
749
  broadcast_all(type: "upgrade_log", line: "Starting upgrade: gem update openclacky --no-document\n")
719
750
 
720
751
  shell = Clacky::Tools::Shell.new
752
+ Clacky::Logger.info("[Upgrade] Calling shell.execute...")
721
753
  result = shell.execute(command: "gem update openclacky --no-document",
722
- soft_timeout: 300, hard_timeout: 600)
754
+ soft_timeout: 30, hard_timeout: 300)
755
+ Clacky::Logger.info("[Upgrade] shell.execute returned: exit_code=#{result[:exit_code]}")
756
+ Clacky::Logger.info("[Upgrade] stdout=#{result[:stdout].to_s.slice(0, 500)}")
757
+ Clacky::Logger.info("[Upgrade] stderr=#{result[:stderr].to_s.slice(0, 500)}")
758
+
723
759
  output = [result[:stdout], result[:stderr]].join
724
760
  success = result[:exit_code] == 0
725
761
 
762
+ clients_count = @ws_mutex.synchronize { @all_ws_conns.size }
763
+ Clacky::Logger.info("[Upgrade] Broadcasting output to #{clients_count} WS client(s)")
726
764
  broadcast_all(type: "upgrade_log", line: output)
727
765
 
728
766
  if success
767
+ Clacky::Logger.info("[Upgrade] Success!")
729
768
  broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
730
769
  broadcast_all(type: "upgrade_complete", success: true)
731
770
  else
771
+ Clacky::Logger.warn("[Upgrade] Failed. exit_code=#{result[:exit_code]}")
732
772
  broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: gem update openclacky\n")
733
773
  broadcast_all(type: "upgrade_complete", success: false)
734
774
  end
735
775
  rescue StandardError => e
776
+ Clacky::Logger.error("[Upgrade] Exception: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
736
777
  broadcast_all(type: "upgrade_log", line: "\n✗ Error during upgrade: #{e.message}\n")
737
778
  broadcast_all(type: "upgrade_complete", success: false)
738
779
  end
@@ -746,24 +787,38 @@ module Clacky
746
787
  def api_restart(req, res)
747
788
  json_response(res, 200, { ok: true, message: "Restarting…" })
748
789
 
749
- script = @restart_script
750
- argv = @restart_argv
751
790
  Thread.new do
752
791
  sleep 0.5 # Let WEBrick flush the HTTP response
753
792
 
754
- # Use login shell to re-exec so rbenv/mise shims resolve the newly installed gem version.
755
- # Direct `exec(RbConfig.ruby, script, *argv)` would reuse the old Ruby interpreter path
756
- # and miss gem updates installed under a different Ruby version managed by rbenv/mise.
757
- shell = ENV["SHELL"].to_s
758
- shell = "/bin/bash" if shell.empty?
759
- cmd_parts = [Shellwords.escape(script), *argv.map { |a| Shellwords.escape(a) }]
760
- cmd_string = cmd_parts.join(" ")
761
-
762
- Clacky::Logger.info("[Restart] exec: #{shell} -l -c #{cmd_string}")
763
- exec(shell, "-l", "-c", cmd_string)
793
+ if @master_pid
794
+ # Worker mode: tell master to hot-restart, then exit cleanly.
795
+ Clacky::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
796
+ begin
797
+ Process.kill("USR1", @master_pid)
798
+ rescue Errno::ESRCH
799
+ Clacky::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
800
+ standalone_exec_restart
801
+ end
802
+ exit(0)
803
+ else
804
+ # Standalone mode (no master): fall back to the original exec approach.
805
+ standalone_exec_restart
806
+ end
764
807
  end
765
808
  end
766
809
 
810
+ # Re-exec the current process via a login shell (rbenv/mise shim compatible).
811
+ private def standalone_exec_restart
812
+ script = @restart_script
813
+ argv = @restart_argv
814
+ shell = ENV["SHELL"].to_s
815
+ shell = "/bin/bash" if shell.empty?
816
+ cmd_parts = [Shellwords.escape(script), *argv.map { |a| Shellwords.escape(a) }]
817
+ cmd_string = cmd_parts.join(" ")
818
+ Clacky::Logger.info("[Restart] exec: #{shell} -l -c #{cmd_string}")
819
+ exec(shell, "-l", "-c", cmd_string)
820
+ end
821
+
767
822
  # Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
768
823
  # Uses Clacky::Tools::Shell (login shell) so rbenv/mise shims and gem mirrors work correctly.
769
824
  private def fetch_latest_version_cached
@@ -1565,6 +1620,7 @@ module Clacky
1565
1620
  end
1566
1621
 
1567
1622
  def on_ws_open(conn)
1623
+ @ws_mutex.synchronize { @all_ws_conns << conn }
1568
1624
  # Client will send a "subscribe" message to bind to a session
1569
1625
  end
1570
1626
 
@@ -1638,6 +1694,7 @@ module Clacky
1638
1694
  end
1639
1695
 
1640
1696
  def on_ws_close(conn)
1697
+ @ws_mutex.synchronize { @all_ws_conns.delete(conn) }
1641
1698
  unsubscribe(conn)
1642
1699
  end
1643
1700
 
@@ -1772,9 +1829,9 @@ module Clacky
1772
1829
  clients.each { |conn| conn.send_json(event) rescue nil }
1773
1830
  end
1774
1831
 
1775
- # Broadcast an event to every connected client.
1832
+ # Broadcast an event to every connected client (regardless of session subscription).
1776
1833
  def broadcast_all(event)
1777
- clients = @ws_mutex.synchronize { @ws_clients.values.flatten.uniq }
1834
+ clients = @ws_mutex.synchronize { @all_ws_conns.dup }
1778
1835
  clients.each { |conn| conn.send_json(event) rescue nil }
1779
1836
  end
1780
1837
 
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "tmpdir"
5
+ require_relative "../banner"
6
+ require_relative "../version"
7
+
8
+ module Clacky
9
+ module Server
10
+ # Master process — owns the listen socket, spawns/monitors worker processes.
11
+ #
12
+ # Lifecycle:
13
+ # clacky server
14
+ # └─ Master.run (this file)
15
+ # ├─ creates TCPServer, holds it forever
16
+ # ├─ spawns Worker via spawn() — full new Ruby process, loads fresh gem
17
+ # ├─ traps USR1 → hot_restart (spawn new worker, gracefully stop old)
18
+ # └─ traps TERM/INT → shutdown (stop worker, exit cleanly)
19
+ #
20
+ # Worker receives:
21
+ # CLACKY_WORKER=1 — "I am a worker, start HttpServer directly"
22
+ # CLACKY_INHERIT_FD=<n> — file descriptor number of the inherited TCPServer socket
23
+ # CLACKY_MASTER_PID=<n> — master PID so worker can send USR1 back on upgrade
24
+ class Master
25
+ # Worker exits with this code to request a hot restart (e.g. after gem upgrade).
26
+ RESTART_EXIT_CODE = 75
27
+ MAX_CONSECUTIVE_FAILURES = 5
28
+
29
+ # How long (seconds) to wait for a new worker to become ready before killing the old one.
30
+ NEW_WORKER_BOOT_WAIT = 3
31
+
32
+ def initialize(host:, port:, argv: nil, extra_flags: [])
33
+ @host = host
34
+ @port = port
35
+ @argv = argv # kept for backward compat but no longer used
36
+ @extra_flags = extra_flags # e.g. ["--brand-test"]
37
+
38
+ @socket = nil
39
+ @worker_pid = nil
40
+ @restart_requested = false
41
+ @shutdown_requested = false
42
+ end
43
+
44
+ def run
45
+ # 0. Print banner first — before any log output
46
+ print_banner
47
+
48
+ # 1. Kill any existing master on this port before binding.
49
+ kill_existing_master
50
+
51
+ # 2. Bind the socket once — master holds it for the entire lifetime.
52
+ @socket = TCPServer.new(@host, @port)
53
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
54
+
55
+ write_pid_file
56
+
57
+ # 3. Signal handlers
58
+ Signal.trap("USR1") { @restart_requested = true }
59
+ Signal.trap("TERM") { @shutdown_requested = true }
60
+ Signal.trap("INT") { @shutdown_requested = true }
61
+
62
+ # 4. Spawn first worker
63
+ @worker_pid = spawn_worker
64
+ @consecutive_failures = 0
65
+
66
+ # 4. Monitor loop
67
+ loop do
68
+ if @shutdown_requested
69
+ shutdown
70
+ break
71
+ end
72
+
73
+ if @restart_requested
74
+ @restart_requested = false
75
+ hot_restart
76
+ @consecutive_failures = 0
77
+ end
78
+
79
+ # Non-blocking wait: check if worker has exited
80
+ pid, status = Process.waitpid2(@worker_pid, Process::WNOHANG)
81
+ if pid
82
+ exit_code = status.exitstatus
83
+ if exit_code == RESTART_EXIT_CODE
84
+ Clacky::Logger.info("[Master] Worker requested restart (exit #{RESTART_EXIT_CODE}).")
85
+ @worker_pid = spawn_worker
86
+ @consecutive_failures = 0
87
+ elsif @shutdown_requested
88
+ break
89
+ else
90
+ @consecutive_failures += 1
91
+ if @consecutive_failures >= MAX_CONSECUTIVE_FAILURES
92
+ Clacky::Logger.error("[Master] Worker failed #{MAX_CONSECUTIVE_FAILURES} times in a row, giving up.")
93
+ shutdown
94
+ break
95
+ end
96
+ delay = [0.5 * (2 ** (@consecutive_failures - 1)), 30].min # exponential backoff, max 30s
97
+ Clacky::Logger.warn("[Master] Worker exited unexpectedly (exit #{exit_code}), failure #{@consecutive_failures}/#{MAX_CONSECUTIVE_FAILURES}, restarting in #{delay}s...")
98
+ sleep delay
99
+ @worker_pid = spawn_worker
100
+ end
101
+ end
102
+
103
+ sleep 0.1
104
+ end
105
+ ensure
106
+ remove_pid_file
107
+ end
108
+
109
+ private
110
+
111
+ # Spawn a fresh Ruby process that loads the (possibly updated) gem from disk.
112
+ # The listen socket is inherited via its file descriptor number.
113
+ def spawn_worker
114
+ env = {
115
+ "CLACKY_WORKER" => "1",
116
+ "CLACKY_INHERIT_FD" => @socket.fileno.to_s,
117
+ "CLACKY_MASTER_PID" => Process.pid.to_s
118
+ }
119
+ # Keep the socket fd open across exec — mark it as non-CLOEXEC.
120
+ @socket.close_on_exec = false
121
+
122
+ # Reconstruct the worker command explicitly.
123
+ # We cannot rely on ARGV (Thor has already consumed it), so we rebuild
124
+ # the minimal args: `clacky server --host HOST --port PORT [extra_flags]`
125
+ ruby = RbConfig.ruby
126
+ script = File.expand_path($0)
127
+ worker_argv = ["server", "--host", @host.to_s, "--port", @port.to_s] + @extra_flags
128
+
129
+ Clacky::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
130
+ Clacky::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
131
+ pid = spawn(env, ruby, script, *worker_argv)
132
+ Clacky::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid}")
133
+ pid
134
+ end
135
+
136
+ # Spawn a new worker, wait for it to boot, then gracefully stop the old one.
137
+ def hot_restart
138
+ old_pid = @worker_pid
139
+ Clacky::Logger.info("[Master] Hot restart: spawning new worker (old PID=#{old_pid})...")
140
+
141
+ new_pid = spawn_worker
142
+ @worker_pid = new_pid
143
+
144
+ # Give the new worker time to bind and start serving
145
+ sleep NEW_WORKER_BOOT_WAIT
146
+
147
+ # Gracefully stop old worker
148
+ begin
149
+ Process.kill("TERM", old_pid)
150
+ # Reap it (non-blocking loop so we don't block the monitor)
151
+ deadline = Time.now + 5
152
+ loop do
153
+ pid, = Process.waitpid2(old_pid, Process::WNOHANG)
154
+ break if pid
155
+ break if Time.now > deadline
156
+ sleep 0.1
157
+ end
158
+ Process.kill("KILL", old_pid) rescue nil # force-kill if still alive
159
+ rescue Errno::ESRCH
160
+ # already gone — fine
161
+ end
162
+
163
+ Clacky::Logger.info("[Master] Hot restart complete. New worker PID=#{new_pid}")
164
+ end
165
+
166
+ def shutdown
167
+ Clacky::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid})...")
168
+ if @worker_pid
169
+ begin
170
+ Process.kill("TERM", @worker_pid)
171
+ # Wait up to 2s for worker graceful exit, then KILL
172
+ deadline = Time.now + 2
173
+ loop do
174
+ pid, = Process.waitpid2(@worker_pid, Process::WNOHANG)
175
+ break if pid
176
+ if Time.now > deadline
177
+ Clacky::Logger.warn("[Master] Worker did not exit in time, sending KILL...")
178
+ Process.kill("KILL", @worker_pid) rescue nil
179
+ break
180
+ end
181
+ sleep 0.1
182
+ end
183
+ rescue Errno::ESRCH, Errno::ECHILD
184
+ # already gone
185
+ end
186
+ end
187
+ @socket.close rescue nil
188
+ Clacky::Logger.info("[Master] Exited.")
189
+ exit(0)
190
+ end
191
+
192
+ def pid_file_path
193
+ File.join(Dir.tmpdir, "clacky-master-#{@port}.pid")
194
+ end
195
+
196
+ def write_pid_file
197
+ File.write(pid_file_path, Process.pid.to_s)
198
+ end
199
+
200
+ def remove_pid_file
201
+ File.delete(pid_file_path) if File.exist?(pid_file_path)
202
+ end
203
+
204
+ def port_free_within?(seconds)
205
+ deadline = Time.now + seconds
206
+ loop do
207
+ begin
208
+ TCPServer.new(@host, @port).close
209
+ return true
210
+ rescue Errno::EADDRINUSE
211
+ return false if Time.now > deadline
212
+ sleep 0.1
213
+ end
214
+ end
215
+ end
216
+
217
+ def print_banner
218
+ banner = Clacky::Banner.new
219
+ puts ""
220
+ puts banner.colored_cli_logo
221
+ puts banner.colored_tagline
222
+ puts ""
223
+ puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
224
+ puts " Version: #{Clacky::VERSION}"
225
+ puts " Press Ctrl-C to stop."
226
+ puts ""
227
+ end
228
+
229
+ def kill_existing_master
230
+ return unless File.exist?(pid_file_path)
231
+
232
+ pid = File.read(pid_file_path).strip.to_i
233
+ return if pid <= 0
234
+
235
+ begin
236
+ Process.kill("TERM", pid)
237
+ Clacky::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}), waiting up to 3s...")
238
+
239
+ unless port_free_within?(3)
240
+ Clacky::Logger.warn("[Master] Port #{@port} still in use after 3s, sending KILL to PID=#{pid}...")
241
+ Process.kill("KILL", pid) rescue Errno::ESRCH
242
+ unless port_free_within?(2)
243
+ Clacky::Logger.error("[Master] Port #{@port} still in use after KILL, giving up.")
244
+ exit(1)
245
+ end
246
+ end
247
+
248
+ Clacky::Logger.info("[Master] Port #{@port} is now free.")
249
+ rescue Errno::ESRCH
250
+ Clacky::Logger.info("[Master] Existing master PID=#{pid} already gone.")
251
+ rescue Errno::EPERM
252
+ Clacky::Logger.warn("[Master] Could not stop existing master (PID=#{pid}) — permission denied.")
253
+ exit(1)
254
+ ensure
255
+ File.delete(pid_file_path) if File.exist?(pid_file_path)
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -29,12 +29,14 @@ module Clacky
29
29
  end
30
30
 
31
31
  # Expand ~ to home directory only if path starts with ~
32
- # Relative paths are kept as-is to avoid expanding to long absolute paths
32
+ # Relative paths are resolved against working_dir if provided
33
33
  # @param path [String, nil] The path to expand
34
+ # @param working_dir [String, nil] The working directory to resolve relative paths against
34
35
  # @return [String, nil] The expanded path, or original if no ~ present
35
- private def expand_path(path)
36
+ private def expand_path(path, working_dir: nil)
36
37
  return path if path.nil? || path.strip.empty?
37
38
  return File.expand_path(path) if path.start_with?("~")
39
+ return File.expand_path(path, working_dir) if working_dir && !path.start_with?("/")
38
40
 
39
41
  path
40
42
  end
@@ -32,8 +32,8 @@ module Clacky
32
32
  }
33
33
 
34
34
  def execute(path:, old_string:, new_string:, replace_all: false, working_dir: nil)
35
- # Expand ~ to home directory
36
- path = expand_path(path)
35
+ # Expand ~ to home directory, resolve relative paths against working_dir
36
+ path = expand_path(path, working_dir: working_dir)
37
37
 
38
38
  unless File.exist?(path)
39
39
  return { error: "File not found: #{path}" }
@@ -28,8 +28,8 @@ module Clacky
28
28
  end
29
29
 
30
30
  begin
31
- # Expand ~ to home directory
32
- path = expand_path(path)
31
+ # Expand ~ to home directory, resolve relative paths against working_dir
32
+ path = expand_path(path, working_dir: working_dir)
33
33
 
34
34
  # Ensure parent directory exists
35
35
  dir = File.dirname(path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.9"
4
+ VERSION = "0.9.10"
5
5
  end
@@ -102,6 +102,23 @@ body {
102
102
  }
103
103
  #app { display: flex; flex-direction: column; width: 100%; height: 100vh; }
104
104
 
105
+ /* ── Offline Banner ───────────────────────────────────────────────────── */
106
+ #offline-banner {
107
+ position: fixed;
108
+ top: 12px;
109
+ left: 50%;
110
+ transform: translateX(-50%);
111
+ z-index: 9999;
112
+ padding: 6px 16px;
113
+ border-radius: 6px;
114
+ font-size: 12px;
115
+ font-weight: 500;
116
+ white-space: nowrap;
117
+ background: var(--color-warning-bg);
118
+ color: var(--color-warning);
119
+ border: 1px solid var(--color-warning-border);
120
+ }
121
+
105
122
  /* ── Content Row (Sidebar + Main) ───────────────────────────────────────── */
106
123
  #content-row {
107
124
  display: flex;
@@ -1423,6 +1440,19 @@ body {
1423
1440
  #sib-cost { flex-shrink: 0; }
1424
1441
 
1425
1442
  /* ── Input area (wraps preview strip + input bar) ────────────────────────── */
1443
+ #ws-disconnect-hint {
1444
+ position: absolute;
1445
+ bottom: 100%;
1446
+ right: 16px;
1447
+ white-space: nowrap;
1448
+ padding: 2px 8px;
1449
+ font-size: 11px;
1450
+ color: var(--color-warning);
1451
+ pointer-events: none;
1452
+ opacity: 1;
1453
+ transition: opacity 0.4s ease;
1454
+ }
1455
+
1426
1456
  #input-area {
1427
1457
  position: relative;
1428
1458
  border-top: 1px solid var(--color-border-primary);
@@ -1694,7 +1724,10 @@ body {
1694
1724
  max-height: 200px;
1695
1725
  font-family: inherit;
1696
1726
  line-height: 1.5;
1727
+ overflow-y: auto;
1728
+ scrollbar-width: none;
1697
1729
  }
1730
+ #user-input::-webkit-scrollbar { display: none; }
1698
1731
  #user-input:focus { outline: none; border-color: var(--color-accent-primary); }
1699
1732
  #btn-send, #btn-interrupt {
1700
1733
  border: none;
@@ -4284,20 +4317,39 @@ body.setup-mode[data-theme="dark"] {
4284
4317
  /* ── Version badge (inline in Settings row, right-aligned) ──────────────── */
4285
4318
 
4286
4319
  /* Settings button stretches to fill width; version badge pushes to the right */
4287
- #btn-settings {
4288
- position: relative;
4320
+ /* Settings row: btn-settings + version badge side by side */
4321
+ .sidebar-nav-row {
4322
+ display: flex;
4323
+ align-items: stretch;
4324
+ border-radius: 6px;
4325
+ overflow: hidden;
4326
+ transition: background .15s;
4327
+ }
4328
+ .sidebar-nav-row:hover {
4329
+ background: var(--color-bg-hover);
4330
+ }
4331
+ .sidebar-nav-row #btn-settings {
4332
+ flex: 1;
4333
+ border-radius: 0;
4334
+ min-width: 0;
4335
+ background: transparent !important; /* row handles the bg */
4336
+ }
4337
+ .sidebar-nav-row:hover #btn-settings {
4338
+ color: var(--color-text-primary);
4339
+ }
4340
+ .sidebar-nav-row #btn-settings.active {
4341
+ color: var(--color-accent-primary);
4289
4342
  }
4290
4343
 
4291
4344
  .version-badge {
4292
4345
  display: inline-flex;
4293
4346
  align-items: center;
4294
4347
  gap: 5px;
4295
- margin-left: auto; /* push to right inside flex btn-settings */
4296
- padding: 2px 6px;
4297
- border-radius: 4px;
4298
- cursor: pointer; /* always clickable — shows status or upgrade popover */
4299
- transition: background .15s;
4348
+ padding: 0 10px;
4300
4349
  flex-shrink: 0;
4350
+ border-radius: 0;
4351
+ cursor: pointer;
4352
+ transition: background .15s;
4301
4353
  }
4302
4354
 
4303
4355
  .version-text {
@@ -4309,7 +4361,7 @@ body.setup-mode[data-theme="dark"] {
4309
4361
  line-height: 1;
4310
4362
  }
4311
4363
  /* Highlight version text when badge is actionable and btn is hovered */
4312
- #btn-settings:hover .version-text { color: var(--color-text-tertiary); }
4364
+ .version-badge:hover .version-text { color: var(--color-text-tertiary); }
4313
4365
  .version-badge.has-update .version-text,
4314
4366
  .version-badge.upgrade-done .version-text { color: var(--color-accent-primary); }
4315
4367
 
@@ -285,11 +285,24 @@ WS.onEvent(ev => {
285
285
  switch (ev.type) {
286
286
 
287
287
  // ── Internal WS lifecycle ──────────────────────────────────────────
288
- case "_ws_connected":
288
+ case "_ws_connected": {
289
+ const banner = document.getElementById("offline-banner");
290
+ if (banner) banner.style.display = "none";
291
+ const hint = $("ws-disconnect-hint");
292
+ if (hint) hint.style.display = "none";
289
293
  break;
294
+ }
290
295
 
291
- case "_ws_disconnected":
296
+ case "_ws_disconnected": {
297
+ const banner = document.getElementById("offline-banner");
298
+ if (banner) {
299
+ banner.textContent = I18n.t("offline.banner");
300
+ banner.style.display = "block";
301
+ }
302
+ Sessions.clearProgress();
303
+ Sessions.updateStatusBar("idle");
292
304
  break;
305
+ }
293
306
 
294
307
  // ── Session list ───────────────────────────────────────────────────
295
308
  case "session_list": {
@@ -654,6 +667,21 @@ function sendMessage() {
654
667
  if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
655
668
  if (!Sessions.activeId) return;
656
669
 
670
+ if (!WS.ready) {
671
+ const hint = $("ws-disconnect-hint");
672
+ if (hint) {
673
+ hint.textContent = I18n.t("chat.disconnected.hint");
674
+ hint.style.display = "block";
675
+ hint.style.opacity = "1";
676
+ clearTimeout(hint._hideTimer);
677
+ hint._hideTimer = setTimeout(() => {
678
+ hint.style.opacity = "0";
679
+ setTimeout(() => { hint.style.display = "none"; }, 400);
680
+ }, 2000);
681
+ }
682
+ return;
683
+ }
684
+
657
685
  _sending = true;
658
686
 
659
687
  let bubbleHtml = content ? escapeHtml(content) : "";
@@ -57,6 +57,10 @@ const I18n = (() => {
57
57
  "modal.yes": "Yes",
58
58
  "modal.no": "No",
59
59
 
60
+ // ── Offline banner ──
61
+ "offline.banner": "Server disconnected — reconnecting…",
62
+ "chat.disconnected.hint": "Server disconnected. Please wait for reconnection.",
63
+
60
64
  // ── Version / Upgrade ──
61
65
  "upgrade.desc": "A new version is available. It will install in the background — you can keep using the app.",
62
66
  "upgrade.btn.upgrade": "Upgrade Now",
@@ -64,7 +68,7 @@ const I18n = (() => {
64
68
  "upgrade.btn.restart": "↻ Restart Now",
65
69
  "upgrade.installing": "Installing…",
66
70
  "upgrade.done": "Upgrade complete!",
67
- "upgrade.failed": "Upgrade failed. Please try again.",
71
+ "upgrade.failed": "Upgrade failed. Try again or run manually: gem update openclacky",
68
72
  "upgrade.reconnecting": "Restarting server…",
69
73
  "upgrade.restart.success": "✓ Restarted successfully!",
70
74
  "upgrade.tooltip.upgrading": "Upgrading — click to see progress",
@@ -326,6 +330,10 @@ const I18n = (() => {
326
330
  "modal.yes": "确认",
327
331
  "modal.no": "取消",
328
332
 
333
+ // ── Offline banner ──
334
+ "offline.banner": "服务已断开,正在重连…",
335
+ "chat.disconnected.hint": "服务已断开,请等待重连后再发送。",
336
+
329
337
  // ── Version / Upgrade ──
330
338
  "upgrade.desc": "有新版本可用,将在后台安装,升级期间可继续使用。",
331
339
  "upgrade.btn.upgrade": "立即升级",
@@ -333,7 +341,7 @@ const I18n = (() => {
333
341
  "upgrade.btn.restart": "↻ 立即重启",
334
342
  "upgrade.installing": "安装中…",
335
343
  "upgrade.done": "升级完成!",
336
- "upgrade.failed": "升级失败,请重试。",
344
+ "upgrade.failed": "升级失败,请重试或手动执行:gem update openclacky",
337
345
  "upgrade.reconnecting": "服务重启中…",
338
346
  "upgrade.restart.success": "✓ 重启成功!",
339
347
  "upgrade.tooltip.upgrading": "升级中,点击查看进度",
@@ -16,6 +16,7 @@
16
16
  </script>
17
17
  </head>
18
18
  <body>
19
+ <div id="offline-banner" style="display:none" data-i18n="offline.banner"></div>
19
20
  <div id="app" style="visibility:hidden">
20
21
 
21
22
  <!-- ── TOP HEADER ──────────────────────────────────────────────────────── -->
@@ -129,13 +130,15 @@
129
130
 
130
131
  <!-- Bottom Settings -->
131
132
  <div id="sidebar-footer">
132
- <button id="btn-settings" class="sidebar-nav-btn" title="Settings">
133
- <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134
- <circle cx="12" cy="12" r="3"/>
135
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
136
- </svg>
137
- <span data-i18n="sidebar.settings">Settings</span>
138
- <!-- Version badge: inline in settings row, right-aligned -->
133
+ <div class="sidebar-nav-row">
134
+ <button id="btn-settings" class="sidebar-nav-btn" title="Settings">
135
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
136
+ <circle cx="12" cy="12" r="3"/>
137
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
138
+ </svg>
139
+ <span data-i18n="sidebar.settings">Settings</span>
140
+ </button>
141
+ <!-- Version badge: independent button, right side of the row -->
139
142
  <span id="version-badge" class="version-badge" style="display:none">
140
143
  <span id="version-text" class="version-text"></span>
141
144
  <span id="version-update-dot" class="version-update-dot" style="display:none"></span>
@@ -143,7 +146,7 @@
143
146
  <span id="version-spinner" class="version-spinner" style="display:none"></span>
144
147
  <span id="version-done-check" class="version-done-check" style="display:none">✓</span>
145
148
  </span>
146
- </button>
149
+ </div>
147
150
  </div>
148
151
  </aside>
149
152
 
@@ -220,6 +223,7 @@
220
223
  </div>
221
224
 
222
225
  <div id="input-area">
226
+ <div id="ws-disconnect-hint" style="display:none"></div>
223
227
  <!-- Skill autocomplete dropdown (shown when user types /xxx) -->
224
228
  <div id="skill-autocomplete" style="display:none" role="listbox" aria-label="Skills">
225
229
  <div id="skill-autocomplete-list"></div>
@@ -508,10 +508,16 @@ const Sessions = (() => {
508
508
  }
509
509
  },
510
510
 
511
- /** Patch a single session's fields (from session_update event). */
511
+ /** Patch a single session's fields (from session_update event).
512
+ * If the session is not in the list yet (e.g. just created by another tab),
513
+ * prepend it so the sidebar shows it immediately. */
512
514
  patch(id, fields) {
513
515
  const s = _sessions.find(s => s.id === id);
514
- if (s) Object.assign(s, fields);
516
+ if (s) {
517
+ Object.assign(s, fields);
518
+ } else {
519
+ _sessions.unshift({ id, ...fields });
520
+ }
515
521
  },
516
522
 
517
523
  /** Remove a session from the list (from session_deleted event). */
@@ -305,12 +305,13 @@ const Version = (() => {
305
305
  logEl.scrollTop = logEl.scrollHeight;
306
306
  }
307
307
  } else if (event.type === "upgrade_complete") {
308
- _upgrading = false;
309
- _needsUpdate = false;
308
+ _upgrading = false;
310
309
  if (event.success) {
310
+ _needsUpdate = false;
311
311
  _needsRestart = true; // badge: orange bouncing dot
312
312
  _upgradeDone = false;
313
313
  }
314
+ // On failure, _needsUpdate stays true so badge stays amber
314
315
  _renderBadge();
315
316
  // Morph popover to done/error state
316
317
  const pop = $("version-upgrade-popover");
@@ -325,27 +326,50 @@ const Version = (() => {
325
326
  }
326
327
 
327
328
  // ── Init ───────────────────────────────────────────────────────────────
329
+ let _hoverTimer = null;
330
+
328
331
  function init() {
329
332
  const badge = $("version-badge");
330
333
  if (badge) {
334
+ // Click still works (e.g. during reconnect to keep popover visible)
331
335
  badge.addEventListener("click", e => {
332
- if (!_current) return;
333
-
334
- // Always stop propagation — badge click should never open the settings panel
335
336
  e.stopPropagation();
336
-
337
- // During reconnect, badge click just keeps popover visible (no toggle)
338
337
  if (_reconnecting) { if (!_popoverOpen) _openPopover(); return; }
338
+ });
339
+
340
+ // Hover to open
341
+ badge.addEventListener("mouseenter", () => {
342
+ if (!_current) return;
343
+ clearTimeout(_hoverTimer);
344
+ _openPopover();
345
+ });
339
346
 
340
- if (_popoverOpen) {
347
+ // Leave badge or popover → close (with small delay so mouse can move to popover)
348
+ badge.addEventListener("mouseleave", () => {
349
+ _hoverTimer = setTimeout(() => {
350
+ const pop = $("version-upgrade-popover");
351
+ if (pop && pop.matches(":hover")) return;
341
352
  _closePopover();
342
- } else {
343
- _openPopover();
344
- }
353
+ }, 200);
345
354
  });
346
355
  }
347
356
 
348
- // Close on outside click but not while upgrading or reconnecting
357
+ // Keep popover open while hovering it; close when leaving
358
+ document.addEventListener("mouseover", e => {
359
+ const pop = $("version-upgrade-popover");
360
+ if (pop && e.target.closest("#version-upgrade-popover")) {
361
+ clearTimeout(_hoverTimer);
362
+ }
363
+ });
364
+ document.addEventListener("mouseout", e => {
365
+ const pop = $("version-upgrade-popover");
366
+ if (!pop) return;
367
+ if (e.target.closest("#version-upgrade-popover") && !e.relatedTarget?.closest("#version-upgrade-popover") && !e.relatedTarget?.closest("#version-badge")) {
368
+ _hoverTimer = setTimeout(() => _closePopover(), 200);
369
+ }
370
+ });
371
+
372
+ // Click outside still closes (e.g. keyboard users, edge cases)
349
373
  document.addEventListener("click", e => {
350
374
  if (!e.target.closest("#version-badge") && !e.target.closest("#version-upgrade-popover")) {
351
375
  if (_popoverOpen && !_upgrading && !_reconnecting) _closePopover();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.9
4
+ version: 0.9.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -368,6 +368,7 @@ files:
368
368
  - lib/clacky/server/channel/channel_ui_controller.rb
369
369
  - lib/clacky/server/http_server.rb
370
370
  - lib/clacky/server/scheduler.rb
371
+ - lib/clacky/server/server_master.rb
371
372
  - lib/clacky/server/session_registry.rb
372
373
  - lib/clacky/server/web_ui_controller.rb
373
374
  - lib/clacky/session_manager.rb