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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/clacky/cli.rb +61 -30
- data/lib/clacky/server/http_server.rb +95 -38
- data/lib/clacky/server/server_master.rb +260 -0
- data/lib/clacky/tools/base.rb +4 -2
- data/lib/clacky/tools/edit.rb +2 -2
- data/lib/clacky/tools/write.rb +2 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +60 -8
- data/lib/clacky/web/app.js +30 -2
- data/lib/clacky/web/i18n.js +10 -2
- data/lib/clacky/web/index.html +12 -8
- data/lib/clacky/web/sessions.js +8 -2
- data/lib/clacky/web/version.js +36 -12
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 938c620eb9886a37c1e190c5bf2d8c804a6ed86eb496332a26f5f225e832a5ec
|
|
4
|
+
data.tar.gz: c165097e40b1bde5339e9819f7ccdd2a3191ecd24f9233a758a4a2406f5d74db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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 = {}
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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:
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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 { @
|
|
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
|
data/lib/clacky/tools/base.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -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}" }
|
data/lib/clacky/tools/write.rb
CHANGED
|
@@ -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)
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -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
|
-
|
|
4288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/clacky/web/app.js
CHANGED
|
@@ -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) : "";
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -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.
|
|
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": "升级中,点击查看进度",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -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
|
-
<
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
</
|
|
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>
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -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)
|
|
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). */
|
data/lib/clacky/web/version.js
CHANGED
|
@@ -305,12 +305,13 @@ const Version = (() => {
|
|
|
305
305
|
logEl.scrollTop = logEl.scrollHeight;
|
|
306
306
|
}
|
|
307
307
|
} else if (event.type === "upgrade_complete") {
|
|
308
|
-
_upgrading
|
|
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
|
-
|
|
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
|
-
}
|
|
343
|
-
_openPopover();
|
|
344
|
-
}
|
|
353
|
+
}, 200);
|
|
345
354
|
});
|
|
346
355
|
}
|
|
347
356
|
|
|
348
|
-
//
|
|
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.
|
|
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
|