openclacky 0.9.12 → 0.9.13

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: 791fa59b1013c559e294b373e1a7ba671545eef76ae7037ed3c604ed9f269b45
4
- data.tar.gz: 8ffae20491125d886e36387b811f9ea8c2e2fcc76d76d10e08224bcc06a93972
3
+ metadata.gz: d21f7af79e1b66e118c909432e379375359e2bf650099c7bd17669985411409b
4
+ data.tar.gz: 1f927c5ce98c2560773cd0a8011ef0d523c56788fe47e17aec3dc1f952d1cd5c
5
5
  SHA512:
6
- metadata.gz: 6895348aeef5d8ed713273a2d386e390a018e5b4991c37a7fd20b7fdbb4782fc945779b45556f31cb2a0894f78df72efc80ad4661b3b95d89cd8ab0c333b65a0
7
- data.tar.gz: 01c35bb0a3377b81c4e3f89892d58b10472f9611347ed9b0478872e76e3c236def6b499475df32d6020c56fe94dcd33c11495512ee94a5a44a1c81aefbaaad3e
6
+ metadata.gz: a3a51590171a24e10a002d9c89ff5a46e17ff2b09080553e886f156ab53b9dca3a64050a80816678cdd8510c1aa3fe0158732139e07053503c366ddf17e8ea9d
7
+ data.tar.gz: 7c76475762cf8ecf506337ec80fb6aa12eac582d604f8245c801e1c6d89bea7d32f7e508e689a8a1f6bc6ed3f7ec9f7a2ce85682ffb33954d8e6e2178c1def1f
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.13] - 2026-03-27
11
+
12
+ ### Added
13
+ - **Ruby 2.6 compatibility**: the gem now installs cleanly on Ruby 2.6 (including macOS system Ruby 2.6.x) — dependency version constraints for `faraday` and `rouge` are now capped so RubyGems automatically selects compatible versions on older Ruby environments
14
+
15
+ ### Fixed
16
+ - **WebSocket pure-Ruby replacement**: replaced the native WebSocket dependency with a pure-Ruby implementation to improve cross-platform compatibility
17
+ - **Ctrl+C warning in UI suppressed**: fixed a spurious warning printed to the terminal when pressing Ctrl+C in the interactive UI
18
+ - **Parser stderr pollution from Bundler warnings filtered**: Ruby/Bundler version warnings no longer contaminate parser error messages
19
+
10
20
  ## [0.9.12] - 2026-03-27
11
21
 
12
22
  ### Added
@@ -102,16 +102,28 @@ module Clacky
102
102
 
103
103
  # Returns true when a heartbeat should be sent (interval elapsed).
104
104
  def heartbeat_due?
105
- return true if @license_last_heartbeat.nil?
105
+ if @license_last_heartbeat.nil?
106
+ Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)")
107
+ return true
108
+ end
106
109
 
107
- (Time.now.utc - @license_last_heartbeat) >= HEARTBEAT_INTERVAL
110
+ elapsed = Time.now.utc - @license_last_heartbeat
111
+ due = elapsed >= HEARTBEAT_INTERVAL
112
+ Clacky::Logger.debug("[Brand] heartbeat_due? elapsed=#{elapsed.to_i}s interval=#{HEARTBEAT_INTERVAL}s => #{due}")
113
+ due
108
114
  end
109
115
 
110
116
  # Returns true when the grace period for missed heartbeats has expired.
111
117
  def grace_period_exceeded?
112
- return false if @license_last_heartbeat.nil?
118
+ if @license_last_heartbeat.nil?
119
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)")
120
+ return false
121
+ end
113
122
 
114
- (Time.now.utc - @license_last_heartbeat) >= HEARTBEAT_GRACE_PERIOD
123
+ elapsed = Time.now.utc - @license_last_heartbeat
124
+ exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
125
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
126
+ exceeded
115
127
  end
116
128
 
117
129
  # Returns true when the license is bound to a specific user (user_id present).
@@ -221,7 +233,12 @@ module Clacky
221
233
  # Send a heartbeat to the API and update last_heartbeat timestamp.
222
234
  # Returns a result hash: { success: bool, message: String }
223
235
  def heartbeat!
224
- return { success: false, message: "License not activated" } unless activated?
236
+ unless activated?
237
+ Clacky::Logger.debug("[Brand] heartbeat! skipped — license not activated")
238
+ return { success: false, message: "License not activated" }
239
+ end
240
+
241
+ Clacky::Logger.info("[Brand] heartbeat! sending — last_heartbeat=#{@license_last_heartbeat&.iso8601 || "nil"} expires_at=#{@license_expires_at&.iso8601 || "nil"}")
225
242
 
226
243
  user_id = parse_user_id_from_key(@license_key)
227
244
  key_hash = Digest::SHA256.hexdigest(@license_key)
@@ -246,8 +263,10 @@ module Clacky
246
263
  @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
247
264
  apply_distribution(response[:data]["distribution"])
248
265
  save
266
+ Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
249
267
  { success: true, message: "Heartbeat OK" }
250
268
  else
269
+ Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}")
251
270
  { success: false, message: response[:error] || "Heartbeat failed" }
252
271
  end
253
272
  end
data/lib/clacky/cli.rb CHANGED
@@ -252,12 +252,16 @@ module Clacky
252
252
  brand = Clacky::BrandConfig.load
253
253
  return unless brand.branded?
254
254
 
255
+ 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"}")
256
+
255
257
  unless brand.activated?
258
+ Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
256
259
  cli_prompt_license_activation(brand)
257
260
  return
258
261
  end
259
262
 
260
263
  if brand.expired?
264
+ Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
261
265
  say ""
262
266
  say "WARNING: Your #{brand.product_name} license has expired. Please renew to continue.", :yellow
263
267
  say ""
@@ -265,17 +269,25 @@ module Clacky
265
269
  end
266
270
 
267
271
  if brand.heartbeat_due?
272
+ Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, sending...")
268
273
  result = brand.heartbeat!
269
- unless result[:success]
270
- if brand.grace_period_exceeded?
271
- say ""
272
- say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
273
- say "License has been offline for more than 3 days. Please check your connection.", :yellow
274
- say ""
275
- else
276
- say "(License heartbeat failed - will retry tomorrow.)", :cyan
274
+ if result[:success]
275
+ Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat OK")
276
+ else
277
+ Clacky::Logger.warn("[Brand] check_brand_license_cli: heartbeat failed #{result[:message]} grace_exceeded=#{brand.grace_period_exceeded?}")
278
+ unless result[:success]
279
+ if brand.grace_period_exceeded?
280
+ say ""
281
+ say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
282
+ say "License has been offline for more than 3 days. Please check your connection.", :yellow
283
+ say ""
284
+ else
285
+ say "(License heartbeat failed - will retry tomorrow.)", :cyan
286
+ end
277
287
  end
278
288
  end
289
+ else
290
+ Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
279
291
  end
280
292
  end
281
293
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "websocket/driver"
3
+ require "websocket"
4
4
  require "json"
5
5
  require "net/http"
6
6
  require "uri"
@@ -47,7 +47,8 @@ module Clacky
47
47
  def stop
48
48
  @running = false
49
49
  @ping_thread&.kill
50
- @ws&.close
50
+ send_raw_frame(:close, "") rescue nil
51
+ @ws_socket&.close rescue nil
51
52
  end
52
53
 
53
54
  private
@@ -73,37 +74,48 @@ module Clacky
73
74
  tcp
74
75
  end
75
76
 
76
- wrapper = SocketWrapper.new(socket, endpoint)
77
- @ws = WebSocket::Driver.client(wrapper)
77
+ # WebSocket handshake
78
+ handshake = WebSocket::Handshake::Client.new(url: endpoint)
79
+ socket.write(handshake.to_s)
78
80
 
79
- @ws.on :open do
80
- Clacky::Logger.info("[feishu-ws] WebSocket connected")
81
+ # Read until handshake complete
82
+ until handshake.finished?
83
+ handshake << socket.readpartial(4096)
81
84
  end
85
+ raise "WebSocket handshake failed" unless handshake.valid?
82
86
 
83
- @ws.on :message do |event|
84
- data = event.data
85
- handle_frame(data.respond_to?(:b) ? data.b : data)
86
- end
87
-
88
- @ws.on :error do |event|
89
- Clacky::Logger.warn("[feishu-ws] WebSocket error: #{event.message}")
90
- end
87
+ Clacky::Logger.info("[feishu-ws] WebSocket connected")
88
+ @ws_version = handshake.version
89
+ @ws_socket = socket
90
+ @ws_open = true
91
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
91
92
 
92
- @ws.on :close do |event|
93
- Clacky::Logger.info("[feishu-ws] WebSocket closed (code=#{event.code}), will reconnect")
94
- end
95
-
96
- @ws.start
97
93
  start_ping_thread
98
94
 
99
95
  loop do
100
96
  break unless @running
101
97
  data = socket.readpartial(4096)
102
- @ws.parse(data)
98
+ @incoming << data
99
+ while (frame = @incoming.next)
100
+ case frame.type
101
+ when :binary
102
+ raw = frame.data
103
+ handle_frame(raw.respond_to?(:b) ? raw.b : raw)
104
+ when :text
105
+ handle_frame(frame.data)
106
+ when :ping
107
+ send_raw_frame(:pong, frame.data)
108
+ when :close
109
+ Clacky::Logger.info("[feishu-ws] WebSocket closed, will reconnect")
110
+ return
111
+ end
112
+ end
103
113
  end
104
114
  rescue EOFError, Errno::ECONNRESET
105
115
  Clacky::Logger.warn("[feishu-ws] Connection lost, reconnecting in #{RECONNECT_DELAY}s...")
106
116
  ensure
117
+ @ws_open = false
118
+ @ws_socket = nil
107
119
  socket&.close rescue nil
108
120
  @ping_thread&.kill
109
121
  end
@@ -211,11 +223,21 @@ module Clacky
211
223
  payload: payload
212
224
  }
213
225
  encoded = ProtoFrame.encode(frame)
214
- @ws.binary(encoded)
226
+ send_raw_frame(:binary, encoded)
215
227
  rescue => e
216
228
  warn "[feishu-ws] failed to send frame: #{e.message}"
217
229
  end
218
230
 
231
+ def send_raw_frame(type, data)
232
+ return unless @ws_socket && @ws_open
233
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
234
+ version: @ws_version || 13,
235
+ data: data,
236
+ type: type
237
+ )
238
+ @ws_socket.write(outgoing.to_s)
239
+ end
240
+
219
241
  def start_ping_thread
220
242
  @ping_thread&.kill
221
243
  @ping_thread = Thread.new do
@@ -365,19 +387,7 @@ module Clacky
365
387
  end
366
388
  end
367
389
 
368
- # Wraps a raw socket for websocket-driver client mode.
369
- class SocketWrapper
370
- attr_reader :url
371
390
 
372
- def initialize(socket, url)
373
- @socket = socket
374
- @url = url
375
- end
376
-
377
- def write(data)
378
- @socket.write(data)
379
- end
380
- end
381
391
  end
382
392
  end
383
393
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "websocket/driver"
3
+ require "websocket"
4
4
  require "json"
5
5
  require "uri"
6
6
  require "securerandom"
@@ -58,7 +58,8 @@ module Clacky
58
58
  def stop
59
59
  @running = false
60
60
  @ping_thread&.kill
61
- @ws&.close
61
+ send_raw_frame(:close, "") rescue nil
62
+ @ws_socket&.close rescue nil
62
63
  end
63
64
 
64
65
  # Proactively send a text message
@@ -130,37 +131,45 @@ module Clacky
130
131
  ssl.sync_close = true
131
132
  ssl.connect
132
133
 
133
- wrapper = SocketWrapper.new(ssl, @ws_url)
134
- @ws = WebSocket::Driver.client(wrapper)
134
+ # WebSocket handshake
135
+ handshake = WebSocket::Handshake::Client.new(url: @ws_url)
136
+ ssl.write(handshake.to_s)
135
137
 
136
- @ws.on :open do
137
- Clacky::Logger.info("[WecomWSClient] connected, authenticating")
138
- authenticate
139
- start_ping_thread
138
+ until handshake.finished?
139
+ handshake << ssl.readpartial(4096)
140
140
  end
141
+ raise "WebSocket handshake failed" unless handshake.valid?
141
142
 
142
- @ws.on :message do |event|
143
- handle_message(event.data)
144
- end
145
-
146
- @ws.on :error do |event|
147
- Clacky::Logger.error("[WecomWSClient] WS error: #{event.message}")
148
- end
143
+ Clacky::Logger.info("[WecomWSClient] connected, authenticating")
144
+ @ws_version = handshake.version
145
+ @ws_socket = ssl
146
+ @ws_open = true
147
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
149
148
 
150
- @ws.on :close do
151
- Clacky::Logger.info("[WecomWSClient] connection closed")
152
- end
153
-
154
- @ws.start
149
+ authenticate
150
+ start_ping_thread
155
151
 
156
152
  loop do
157
153
  break unless @running
158
154
  data = ssl.readpartial(4096)
159
- @ws.parse(data)
155
+ @incoming << data
156
+ while (frame = @incoming.next)
157
+ case frame.type
158
+ when :text
159
+ handle_message(frame.data)
160
+ when :ping
161
+ send_raw_frame(:pong, frame.data)
162
+ when :close
163
+ Clacky::Logger.info("[WecomWSClient] connection closed")
164
+ return
165
+ end
166
+ end
160
167
  end
161
168
  rescue EOFError, Errno::ECONNRESET
162
169
  Clacky::Logger.info("[WecomWSClient] connection lost, reconnecting...")
163
170
  ensure
171
+ @ws_open = false
172
+ @ws_socket = nil
164
173
  ssl&.close rescue nil
165
174
  @ping_thread&.kill
166
175
  end
@@ -227,11 +236,21 @@ module Clacky
227
236
  else
228
237
  Clacky::Logger.info("[WecomWSClient] >> cmd=#{cmd} req_id=#{req_id}")
229
238
  end
230
- @ws.text(JSON.generate(frame))
239
+ send_raw_frame(:text, JSON.generate(frame))
231
240
  rescue => e
232
241
  Clacky::Logger.error("[WecomWSClient] failed to send frame cmd=#{cmd}: #{e.message}")
233
242
  end
234
243
 
244
+ def send_raw_frame(type, data)
245
+ return unless @ws_socket && @ws_open
246
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
247
+ version: @ws_version || 13,
248
+ data: data,
249
+ type: type
250
+ )
251
+ @ws_socket.write(outgoing.to_s)
252
+ end
253
+
235
254
  def start_ping_thread
236
255
  @ping_thread&.kill
237
256
  @ping_thread = Thread.new do
@@ -341,19 +360,7 @@ module Clacky
341
360
  end
342
361
  end
343
362
 
344
- # Wraps a raw socket for websocket-driver client mode.
345
- class SocketWrapper
346
- attr_reader :url
347
363
 
348
- def initialize(socket, url)
349
- @socket = socket
350
- @url = url
351
- end
352
-
353
- def write(data)
354
- @socket.write(data)
355
- end
356
- end
357
364
  end
358
365
  end
359
366
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "webrick"
4
- require "websocket/driver"
4
+ require "websocket"
5
5
  require "json"
6
6
  require "thread"
7
7
  require "fileutils"
@@ -559,6 +559,23 @@ module Clacky
559
559
  return
560
560
  end
561
561
 
562
+ # Send heartbeat if interval has elapsed (once per day)
563
+ if brand.heartbeat_due?
564
+ Clacky::Logger.info("[Brand] api_brand_status: heartbeat due, sending...")
565
+ result = brand.heartbeat!
566
+ if result[:success]
567
+ Clacky::Logger.info("[Brand] api_brand_status: heartbeat OK")
568
+ else
569
+ Clacky::Logger.warn("[Brand] api_brand_status: heartbeat failed — #{result[:message]}")
570
+ end
571
+ # Reload after heartbeat to pick up updated expires_at / last_heartbeat
572
+ brand = Clacky::BrandConfig.load
573
+ else
574
+ Clacky::Logger.debug("[Brand] api_brand_status: heartbeat not due yet")
575
+ end
576
+
577
+ Clacky::Logger.debug("[Brand] api_brand_status: expired=#{brand.expired?} grace_exceeded=#{brand.grace_period_exceeded?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"}")
578
+
562
579
  warning = nil
563
580
  if brand.expired?
564
581
  warning = "Your #{brand.product_name} license has expired. Please renew to continue."
@@ -571,6 +588,8 @@ module Clacky
571
588
  end
572
589
  end
573
590
 
591
+ Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect}")
592
+
574
593
  json_response(res, 200, {
575
594
  branded: true,
576
595
  needs_activation: false,
@@ -1579,26 +1598,27 @@ module Clacky
1579
1598
  req["Upgrade"]&.downcase == "websocket"
1580
1599
  end
1581
1600
 
1582
- # Hijacks the TCP socket from WEBrick and hands it to websocket-driver.
1601
+ # Hijacks the TCP socket from WEBrick and upgrades it to WebSocket.
1583
1602
  def handle_websocket(req, res)
1584
- # Prevent WEBrick from closing the socket after this handler returns
1585
1603
  socket = req.instance_variable_get(:@socket)
1586
1604
 
1587
- driver = WebSocket::Driver.rack(
1588
- RackEnvAdapter.new(req, socket),
1589
- max_length: 10 * 1024 * 1024
1590
- )
1605
+ # Server handshake — parse the upgrade request
1606
+ handshake = WebSocket::Handshake::Server.new
1607
+ handshake << build_handshake_request(req)
1608
+ unless handshake.finished? && handshake.valid?
1609
+ $stderr.puts "WebSocket handshake invalid"
1610
+ return
1611
+ end
1591
1612
 
1592
- conn = WebSocketConnection.new(socket, driver)
1613
+ # Send the 101 Switching Protocols response
1614
+ socket.write(handshake.to_s)
1593
1615
 
1594
- driver.on(:open) { on_ws_open(conn) }
1595
- driver.on(:message) { |event| on_ws_message(conn, event.data) }
1596
- driver.on(:close) { on_ws_close(conn) }
1597
- driver.on(:error) { |event| $stderr.puts "WS error: #{event.message}" }
1616
+ version = handshake.version
1617
+ incoming = WebSocket::Frame::Incoming::Server.new(version: version)
1618
+ conn = WebSocketConnection.new(socket, version)
1598
1619
 
1599
- driver.start
1620
+ on_ws_open(conn)
1600
1621
 
1601
- # Read loop — blocks this thread until the socket closes
1602
1622
  begin
1603
1623
  buf = String.new("", encoding: "BINARY")
1604
1624
  loop do
@@ -1609,14 +1629,27 @@ module Clacky
1609
1629
  when nil
1610
1630
  break # EOF
1611
1631
  else
1612
- driver.parse(chunk)
1632
+ incoming << chunk.dup
1633
+ while (frame = incoming.next)
1634
+ case frame.type
1635
+ when :text
1636
+ on_ws_message(conn, frame.data)
1637
+ when :binary
1638
+ on_ws_message(conn, frame.data)
1639
+ when :ping
1640
+ conn.send_raw(:pong, frame.data)
1641
+ when :close
1642
+ conn.send_raw(:close, "")
1643
+ break
1644
+ end
1645
+ end
1613
1646
  end
1614
1647
  end
1615
1648
  rescue IOError, Errno::ECONNRESET, Errno::EPIPE
1616
1649
  # Client disconnected
1617
1650
  ensure
1618
1651
  on_ws_close(conn)
1619
- driver.close rescue nil
1652
+ socket.close rescue nil
1620
1653
  end
1621
1654
 
1622
1655
  # Tell WEBrick not to send any response (we handled everything)
@@ -1626,6 +1659,14 @@ module Clacky
1626
1659
  $stderr.puts "WebSocket handler error: #{e.class}: #{e.message}"
1627
1660
  end
1628
1661
 
1662
+ # Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
1663
+ private def build_handshake_request(req)
1664
+ lines = ["#{req.request_method} #{req.request_uri.request_uri} HTTP/1.1\r\n"]
1665
+ req.each { |k, v| lines << "#{k}: #{v}\r\n" }
1666
+ lines << "\r\n"
1667
+ lines.join
1668
+ end
1669
+
1629
1670
  def on_ws_open(conn)
1630
1671
  @ws_mutex.synchronize { @all_ws_conns << conn }
1631
1672
  # Client will send a "subscribe" message to bind to a session
@@ -2040,47 +2081,34 @@ module Clacky
2040
2081
 
2041
2082
  # ── Inner classes ─────────────────────────────────────────────────────────
2042
2083
 
2043
- # Thin adapter so websocket-driver (which expects a Rack env) can work with WEBrick.
2044
- class RackEnvAdapter
2045
- def initialize(req, socket)
2046
- @req = req
2047
- @socket = socket
2048
- end
2049
-
2050
- def env
2051
- {
2052
- "REQUEST_METHOD" => @req.request_method,
2053
- "HTTP_HOST" => @req["Host"],
2054
- "REQUEST_URI" => @req.request_uri.to_s,
2055
- "HTTP_UPGRADE" => @req["Upgrade"],
2056
- "HTTP_CONNECTION" => @req["Connection"],
2057
- "HTTP_SEC_WEBSOCKET_KEY" => @req["Sec-WebSocket-Key"],
2058
- "HTTP_SEC_WEBSOCKET_VERSION" => @req["Sec-WebSocket-Version"],
2059
- "rack.hijack" => proc {},
2060
- "rack.input" => StringIO.new
2061
- }
2062
- end
2063
-
2064
- def write(data)
2065
- @socket.write(data)
2066
- end
2067
- end
2068
-
2069
- # Wraps a raw TCP socket + WebSocket driver, providing a thread-safe send method.
2084
+ # Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
2070
2085
  class WebSocketConnection
2071
2086
  attr_accessor :session_id
2072
2087
 
2073
- def initialize(socket, driver)
2088
+ def initialize(socket, version)
2074
2089
  @socket = socket
2075
- @driver = driver
2090
+ @version = version
2076
2091
  @send_mutex = Mutex.new
2077
2092
  end
2078
2093
 
2079
2094
  def send_json(data)
2080
- @send_mutex.synchronize { @driver.text(JSON.generate(data)) }
2095
+ send_raw(:text, JSON.generate(data))
2081
2096
  rescue => e
2082
2097
  $stderr.puts "WS send error: #{e.message}"
2083
2098
  end
2099
+
2100
+ def send_raw(type, data)
2101
+ @send_mutex.synchronize do
2102
+ outgoing = WebSocket::Frame::Outgoing::Server.new(
2103
+ version: @version,
2104
+ data: data,
2105
+ type: type
2106
+ )
2107
+ @socket.write(outgoing.to_s)
2108
+ end
2109
+ rescue => e
2110
+ $stderr.puts "WS send_raw error: #{e.message}"
2111
+ end
2084
2112
  end
2085
2113
  end
2086
2114
  end
@@ -945,6 +945,9 @@ module Clacky
945
945
 
946
946
  handle_key(key)
947
947
  end
948
+ rescue Clacky::AgentInterrupted
949
+ # Ctrl+C raised AgentInterrupted on main thread — stop silently
950
+ stop
948
951
  rescue => e
949
952
  stop
950
953
  raise e
@@ -72,11 +72,14 @@ module Clacky
72
72
 
73
73
  stdout, stderr, status = Open3.capture3(RbConfig.ruby, parser_path, file_path)
74
74
 
75
+ # Filter out Ruby/Bundler version warnings that pollute stderr
76
+ clean_stderr = stderr.lines.reject { |l| l.match?(/warning:|already initialized constant/) }.join.strip
77
+
75
78
  if status.success? && stdout.strip.length > 0
76
79
  { success: true, text: stdout.strip, error: nil, parser_path: parser_path }
77
80
  else
78
81
  { success: false, text: nil,
79
- error: stderr.strip.empty? ? "Parser exited with code #{status.exitstatus}" : stderr.strip,
82
+ error: clean_stderr.empty? ? "Parser exited with code #{status.exitstatus}" : clean_stderr,
80
83
  parser_path: parser_path }
81
84
  end
82
85
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.12"
4
+ VERSION = "0.9.13"
5
5
  end