superkick 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. metadata +404 -0
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Attach
6
+ # Server-side relay/mux for remote attach sessions over raw WebSocket.
7
+ #
8
+ # Manages one agent WebSocket and multiple user WebSockets, routing
9
+ # Attach::Protocol binary frames between them. The relay enforces RW
10
+ # exclusivity, buffers output for replay on new connections, and handles
11
+ # idle timeout auto-demotion.
12
+ #
13
+ # WebSocket objects must respond to #send_binary(data) and #close.
14
+ # The relay is transport-agnostic — the hosted server wraps its WebSocket
15
+ # library to provide this interface.
16
+ class Relay
17
+ DEFAULT_REPLAY_BUFFER_SIZE = 65_536 # 64 KB
18
+ DEFAULT_MAX_CONNECTIONS = 10
19
+ DEFAULT_RW_IDLE_TIMEOUT = 300 # 5 minutes
20
+
21
+ def initialize(agent_id:, config: {})
22
+ @agent_id = agent_id
23
+ @replay_buffer_size = config.fetch(:replay_buffer_size, DEFAULT_REPLAY_BUFFER_SIZE).to_i
24
+ @max_connections = config.fetch(:max_connections, DEFAULT_MAX_CONNECTIONS).to_i
25
+ @rw_idle_timeout = config.fetch(:rw_idle_timeout, DEFAULT_RW_IDLE_TIMEOUT)
26
+
27
+ @agent_ws = nil
28
+ @users = [] # Array of { websocket:, mode: }
29
+ @rw_user = nil # The current RW user websocket (or nil)
30
+ @rw_last_input_at = nil
31
+
32
+ @mutex = Mutex.new
33
+ @history = HistoryBuffer.new(capacity: @replay_buffer_size)
34
+ @idle_check_thread = nil
35
+
36
+ start_idle_check if @rw_idle_timeout && @rw_idle_timeout > 0
37
+ end
38
+
39
+ # --- Agent connection ---
40
+
41
+ # Called when the agent connects via WebSocket.
42
+ def attach_agent(websocket)
43
+ @mutex.synchronize do
44
+ @agent_ws = websocket
45
+ end
46
+ Superkick.logger.info("attach:relay") { "Agent #{@agent_id} attached" }
47
+ end
48
+
49
+ # Called when the agent WebSocket closes.
50
+ def detach_agent
51
+ @mutex.synchronize do
52
+ @agent_ws = nil
53
+
54
+ # Notify all users that the agent disconnected
55
+ error_frame = Superkick::Attach::Protocol.build_frame(
56
+ Superkick::Attach::Protocol::ERROR,
57
+ JSON.generate({message: "Agent disconnected"})
58
+ )
59
+ @users.each do |user|
60
+ user[:websocket].send_binary(error_frame)
61
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
62
+ nil
63
+ end
64
+ end
65
+ Superkick.logger.info("attach:relay") { "Agent #{@agent_id} detached" }
66
+ end
67
+
68
+ def agent_connected?
69
+ @mutex.synchronize { !@agent_ws.nil? }
70
+ end
71
+
72
+ # --- User connections ---
73
+
74
+ # Add a user connection. Enforces RW exclusivity and max connections.
75
+ # Sends META frame and replays history.
76
+ def add_user(websocket, mode:, force: false)
77
+ @mutex.synchronize do
78
+ # Enforce max connections
79
+ if @users.size >= @max_connections
80
+ error_frame = Superkick::Attach::Protocol.build_frame(
81
+ Superkick::Attach::Protocol::ERROR,
82
+ JSON.generate({message: "Maximum connections reached"})
83
+ )
84
+ websocket.send_binary(error_frame)
85
+ websocket.close
86
+ return
87
+ end
88
+
89
+ # Enforce RW exclusivity
90
+ if mode == :rw
91
+ if @rw_user
92
+ if force
93
+ demote_rw_user_locked("Read-write taken over by another client")
94
+ else
95
+ error_frame = Superkick::Attach::Protocol.build_frame(
96
+ Superkick::Attach::Protocol::ERROR,
97
+ JSON.generate({message: "Another read-write client is already connected"})
98
+ )
99
+ websocket.send_binary(error_frame)
100
+ websocket.close
101
+ return
102
+ end
103
+ end
104
+ @rw_user = websocket
105
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ end
107
+
108
+ @users << {websocket:, mode:}
109
+
110
+ # Send META
111
+ meta_frame = Superkick::Attach::Protocol.build_frame(
112
+ Superkick::Attach::Protocol::META,
113
+ JSON.generate({agent_id: @agent_id, history_bytes: @history.size})
114
+ )
115
+ websocket.send_binary(meta_frame)
116
+
117
+ # Replay history
118
+ history_data = @history.snapshot
119
+ unless history_data.empty?
120
+ history_frame = Superkick::Attach::Protocol.build_frame(Superkick::Attach::Protocol::HISTORY, history_data)
121
+ websocket.send_binary(history_frame)
122
+ end
123
+ end
124
+ Superkick.logger.info("attach:relay") { "User added to #{@agent_id} (#{mode})" }
125
+ end
126
+
127
+ # Remove a user connection.
128
+ def remove_user(websocket)
129
+ @mutex.synchronize do
130
+ @users.reject! { it[:websocket] == websocket }
131
+ if @rw_user == websocket
132
+ @rw_user = nil
133
+ @rw_last_input_at = nil
134
+ end
135
+ end
136
+ Superkick.logger.debug("attach:relay") { "User removed from #{@agent_id}" }
137
+ end
138
+
139
+ # --- Frame routing ---
140
+
141
+ # Handle a binary frame from the agent. Parses the frame type,
142
+ # updates history for OUTPUT, and broadcasts to all users.
143
+ def handle_agent_frame(data)
144
+ data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
145
+ return if data.bytesize < 5
146
+
147
+ type = data.getbyte(0)
148
+
149
+ # Buffer OUTPUT frames in history for replay
150
+ if type == Superkick::Attach::Protocol::OUTPUT
151
+ payload_len = data[1, 4].unpack1("N")
152
+ payload = data[5, payload_len]
153
+ @history.write(payload) if payload
154
+ end
155
+
156
+ # Broadcast to all users
157
+ @mutex.synchronize do
158
+ @users.reject! do |user|
159
+ user[:websocket].send_binary(data)
160
+ false
161
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
162
+ @rw_user = nil if @rw_user == user[:websocket]
163
+ true # remove disconnected user
164
+ end
165
+ end
166
+ end
167
+
168
+ # Handle a binary frame from a user. Parses the frame type and routes:
169
+ # - INPUT/RESIZE: forward to agent (RW user only)
170
+ # - COMMAND: handle mode switching locally
171
+ def handle_user_frame(websocket, data)
172
+ data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
173
+ return if data.bytesize < 5
174
+
175
+ type = data.getbyte(0)
176
+
177
+ case type
178
+ when Superkick::Attach::Protocol::INPUT
179
+ @mutex.synchronize do
180
+ if @rw_user == websocket
181
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
182
+ @agent_ws&.send_binary(data)
183
+ end
184
+ # Silently drop INPUT from non-RW users
185
+ end
186
+ when Superkick::Attach::Protocol::RESIZE
187
+ @mutex.synchronize do
188
+ @agent_ws&.send_binary(data) if @rw_user == websocket
189
+ end
190
+ when Superkick::Attach::Protocol::COMMAND
191
+ payload_len = data[1, 4].unpack1("N")
192
+ payload = data[5, payload_len]
193
+ cmd = Superkick::Attach::Protocol.decode_json(payload) if payload
194
+ handle_command(websocket, cmd) if cmd
195
+ end
196
+ end
197
+
198
+ # --- Lifecycle ---
199
+
200
+ def stop
201
+ @idle_check_thread&.kill
202
+ rescue ThreadError
203
+ nil
204
+ end
205
+
206
+ def user_count
207
+ @mutex.synchronize { @users.size }
208
+ end
209
+
210
+ def rw_user
211
+ @mutex.synchronize { @rw_user }
212
+ end
213
+
214
+ private
215
+
216
+ def handle_command(websocket, cmd)
217
+ case cmd[:action]
218
+ when "promote_rw"
219
+ @mutex.synchronize do
220
+ if @rw_user == websocket
221
+ notify_user(websocket, "Already in read-write mode")
222
+ elsif @rw_user
223
+ notify_user(websocket, "Read-write slot is occupied — use forced promotion to take over")
224
+ else
225
+ promote_user_locked(websocket)
226
+ end
227
+ end
228
+ when "force_promote_rw"
229
+ @mutex.synchronize do
230
+ if @rw_user == websocket
231
+ notify_user(websocket, "Already in read-write mode")
232
+ else
233
+ demote_rw_user_locked("Read-write taken over by another client") if @rw_user
234
+ promote_user_locked(websocket)
235
+ end
236
+ end
237
+ when "demote_ro"
238
+ @mutex.synchronize do
239
+ if @rw_user == websocket
240
+ @rw_user = nil
241
+ @rw_last_input_at = nil
242
+ entry = @users.find { it[:websocket] == websocket }
243
+ entry[:mode] = :ro if entry
244
+ notify_user(websocket, "Switched to read-only mode")
245
+ else
246
+ notify_user(websocket, "Already in read-only mode")
247
+ end
248
+ end
249
+ when "claim", "unclaim"
250
+ # Forward claim/unclaim to agent — these are control plane commands
251
+ @mutex.synchronize do
252
+ @agent_ws&.send_binary(Superkick::Attach::Protocol.build_frame(
253
+ Superkick::Attach::Protocol::COMMAND, JSON.generate(cmd)
254
+ ))
255
+ end
256
+ end
257
+ end
258
+
259
+ # Promote a user to RW. MUST be called while holding @mutex.
260
+ def promote_user_locked(websocket)
261
+ @rw_user = websocket
262
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ entry = @users.find { it[:websocket] == websocket }
264
+ entry[:mode] = :rw if entry
265
+ notify_user(websocket, "Promoted to read-write mode")
266
+ end
267
+
268
+ # Demote the current RW user. MUST be called while holding @mutex.
269
+ def demote_rw_user_locked(reason)
270
+ old = @rw_user
271
+ return unless old
272
+
273
+ @rw_user = nil
274
+ @rw_last_input_at = nil
275
+ entry = @users.find { it[:websocket] == old }
276
+ entry[:mode] = :ro if entry
277
+ notify_user(old, reason)
278
+ end
279
+
280
+ def notify_user(websocket, message)
281
+ frame = Superkick::Attach::Protocol.build_frame(
282
+ Superkick::Attach::Protocol::NOTIFY,
283
+ JSON.generate({message:})
284
+ )
285
+ websocket.send_binary(frame)
286
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
287
+ nil
288
+ end
289
+
290
+ def start_idle_check
291
+ @idle_check_thread = Thread.new do
292
+ check_interval = [@rw_idle_timeout / 2.0, 30].min
293
+ loop do
294
+ sleep check_interval
295
+ @mutex.synchronize do
296
+ next unless @rw_user && @rw_last_input_at
297
+
298
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @rw_last_input_at
299
+ if elapsed >= @rw_idle_timeout
300
+ demote_rw_user_locked(
301
+ "Demoted to read-only after #{@rw_idle_timeout.to_i}s of inactivity"
302
+ )
303
+ end
304
+ end
305
+ rescue => e
306
+ Superkick.logger.error("attach:relay") { "Idle check error: #{e.message}" }
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Attach
6
+ # Thread-safe registry of Attach::Relay instances, one per agent.
7
+ # Created at server startup in hosted mode. The hosted server wires
8
+ # WebSocket connections to relays via get_or_create on agent connect
9
+ # and remove on permanent disconnect.
10
+ class RelayStore
11
+ def initialize(config: {})
12
+ @config = config
13
+ @relays = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Get or create a relay for the given agent.
18
+ def get_or_create(agent_id)
19
+ @mutex.synchronize do
20
+ @relays[agent_id] ||= Relay.new(agent_id:, config: @config)
21
+ end
22
+ end
23
+
24
+ # Get an existing relay (nil if none).
25
+ def get(agent_id)
26
+ @mutex.synchronize { @relays[agent_id] }
27
+ end
28
+
29
+ # Remove a relay when the agent disconnects permanently.
30
+ # Calls stop on the relay to clean up the idle timeout thread.
31
+ def remove(agent_id)
32
+ relay = @mutex.synchronize { @relays.delete(agent_id) }
33
+ relay&.stop
34
+ relay
35
+ end
36
+
37
+ # Iterate over all relays.
38
+ def each(&block)
39
+ @mutex.synchronize { @relays.each_value(&block) }
40
+ end
41
+
42
+ def size
43
+ @mutex.synchronize { @relays.size }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Superkick
6
+ module Hosted
7
+ # Abstract base class for agent-side WebSocket bridges that connect local
8
+ # agent components to the hosted server over a persistent WebSocket.
9
+ #
10
+ # Provides the shared WebSocket lifecycle: TCP/TLS connect, auth handshake,
11
+ # read loop, and reconnection with exponential backoff. Subclasses override
12
+ # hook methods to handle channel-specific messages.
13
+ #
14
+ # Uses a simple raw WebSocket protocol (NOT ActionCable):
15
+ # 1. Client connects to a WebSocket endpoint
16
+ # 2. Client sends auth JSON text frame: {"type":"auth","agent_id":"...","api_key":"..."}
17
+ # 3. Server responds: {"type":"welcome"} or {"type":"error","message":"..."}
18
+ # 4. After auth, data frames flow (text or binary depending on channel)
19
+ #
20
+ # Subclasses implement:
21
+ # - websocket_path → URL path (e.g. "/api/v1/agents/#{@agent_id}/buffer/ws")
22
+ # - channel_name → log tag (e.g. "buffer:ws")
23
+ # - handle_server_message(message) → process parsed JSON text messages
24
+ # - handle_binary_message(data) → process binary messages (optional)
25
+ # - on_authenticated → called after welcome (optional)
26
+ class Bridge
27
+ RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30].freeze
28
+ PING_INTERVAL = 30 # seconds
29
+
30
+ def initialize(agent_id:, server_url:, api_key:)
31
+ @agent_id = agent_id
32
+ @server_url = server_url.chomp("/")
33
+ @api_key = api_key
34
+ @thread = nil
35
+ @stop = false
36
+ @reconnect_attempt = 0
37
+ end
38
+
39
+ def start
40
+ @stop = false
41
+ @thread = Thread.new { run_loop }
42
+ self
43
+ end
44
+
45
+ def stop
46
+ @stop = true
47
+ @driver&.close
48
+ @socket&.close
49
+ @thread&.join(5)
50
+ rescue IOError, Errno::EBADF
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ # Subclass hooks — override as needed.
57
+
58
+ def websocket_path
59
+ raise NotImplementedError, "#{self.class} must implement #websocket_path"
60
+ end
61
+
62
+ def channel_name
63
+ raise NotImplementedError, "#{self.class} must implement #channel_name"
64
+ end
65
+
66
+ # Called for each parsed JSON text message after auth is complete.
67
+ def handle_server_message(message)
68
+ end
69
+
70
+ # Called for each binary WebSocket message. Override for binary channels.
71
+ def handle_binary_message(data)
72
+ end
73
+
74
+ # Called once after the server sends a welcome response.
75
+ def on_authenticated
76
+ end
77
+
78
+ # Sending helpers for subclasses.
79
+
80
+ def send_text(data)
81
+ return unless @driver
82
+
83
+ @driver.text(data)
84
+ flush_write_buffer
85
+ end
86
+
87
+ def send_binary(data)
88
+ return unless @driver
89
+
90
+ @driver.binary(data)
91
+ flush_write_buffer
92
+ end
93
+
94
+ # Internal lifecycle.
95
+
96
+ def run_loop
97
+ until @stop
98
+ connect_and_serve
99
+ break if @stop
100
+
101
+ delay = RECONNECT_DELAYS.fetch(@reconnect_attempt) { RECONNECT_DELAYS.last }
102
+ Superkick.logger.info(channel_name) { "Reconnecting in #{delay}s (attempt #{@reconnect_attempt + 1})..." }
103
+ sleep(delay)
104
+ @reconnect_attempt += 1
105
+ end
106
+ rescue => e
107
+ Superkick.logger.error(channel_name) { "WebSocket loop error: #{e.message}" }
108
+ end
109
+
110
+ def connect_and_serve
111
+ uri = URI.parse("#{@server_url}#{websocket_path}")
112
+ @socket = open_socket(uri)
113
+ @driver = create_driver(uri)
114
+
115
+ setup_driver_handlers
116
+ start_driver
117
+
118
+ # Send auth frame
119
+ authenticate
120
+
121
+ # Read loop — blocks until connection closes
122
+ read_loop
123
+
124
+ @reconnect_attempt = 0 # successful connection resets backoff
125
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
126
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, IOError, OpenSSL::SSL::SSLError => e
127
+ Superkick.logger.warn(channel_name) { "Connection failed: #{e.message}" }
128
+ ensure
129
+ begin
130
+ @socket&.close
131
+ rescue IOError, Errno::EBADF
132
+ nil
133
+ end
134
+ @driver = nil
135
+ @socket = nil
136
+ @authenticated = false
137
+ end
138
+
139
+ def open_socket(uri)
140
+ default_port = (uri.scheme == "wss") ? 443 : 80
141
+ port = uri.port || default_port
142
+ tcp = TCPSocket.new(uri.host, port)
143
+
144
+ if uri.scheme == "wss"
145
+ require "openssl"
146
+ ctx = OpenSSL::SSL::SSLContext.new
147
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
148
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
149
+ ssl.hostname = uri.host
150
+ ssl.connect
151
+ ssl
152
+ else
153
+ tcp
154
+ end
155
+ end
156
+
157
+ def create_driver(uri)
158
+ require "websocket/driver"
159
+
160
+ socket_wrapper = SocketWrapper.new(@socket, uri.to_s)
161
+ driver = WebSocket::Driver.client(socket_wrapper)
162
+ driver.set_header("Authorization", "Bearer #{@api_key}")
163
+ driver
164
+ end
165
+
166
+ def setup_driver_handlers
167
+ @authenticated = false
168
+
169
+ @driver.on :open do
170
+ Superkick.logger.info(channel_name) { "WebSocket connected for #{@agent_id}" }
171
+ end
172
+
173
+ @driver.on :message do |event|
174
+ dispatch_text_message(event.data)
175
+ end
176
+
177
+ @driver.on :binary do |event|
178
+ handle_binary_message(event.data)
179
+ end
180
+
181
+ @driver.on :close do
182
+ Superkick.logger.info(channel_name) { "WebSocket closed for #{@agent_id}" }
183
+ end
184
+
185
+ @driver.on :error do |event|
186
+ Superkick.logger.error(channel_name) { "WebSocket error: #{event.message}" }
187
+ end
188
+ end
189
+
190
+ def start_driver
191
+ @driver.start
192
+ flush_write_buffer
193
+ end
194
+
195
+ def authenticate
196
+ auth = {type: "auth", agent_id: @agent_id, api_key: @api_key}
197
+ send_text(JSON.generate(auth))
198
+ end
199
+
200
+ def read_loop
201
+ until @stop
202
+ data = begin
203
+ @socket.read_nonblock(4096)
204
+ rescue IO::WaitReadable
205
+ IO.select([@socket], nil, nil, PING_INTERVAL)
206
+ retry unless @stop
207
+ break
208
+ rescue IOError, Errno::ECONNRESET, Errno::EBADF
209
+ break
210
+ end
211
+
212
+ break unless data && !data.empty?
213
+
214
+ @driver.parse(data)
215
+ flush_write_buffer
216
+ end
217
+ end
218
+
219
+ def dispatch_text_message(data)
220
+ message = JSON.parse(data, symbolize_names: true)
221
+
222
+ case message[:type]
223
+ when "welcome"
224
+ @authenticated = true
225
+ Superkick.logger.info(channel_name) { "Authenticated for #{@agent_id}" }
226
+ on_authenticated
227
+ when "error"
228
+ Superkick.logger.error(channel_name) { "Server error: #{message[:message]}" }
229
+ when "ping"
230
+ send_text(JSON.generate({type: "pong"}))
231
+ when "disconnect"
232
+ Superkick.logger.info(channel_name) { "Server requested disconnect: #{message[:reason]}" }
233
+ else
234
+ handle_server_message(message) if @authenticated
235
+ end
236
+ rescue JSON::ParserError => e
237
+ Superkick.logger.warn(channel_name) { "Invalid JSON from server: #{e.message}" }
238
+ end
239
+
240
+ def flush_write_buffer
241
+ # websocket-driver writes to the SocketWrapper, which writes directly
242
+ end
243
+
244
+ # Minimal wrapper that websocket-driver requires.
245
+ # Must respond to #url and #write.
246
+ class SocketWrapper
247
+ attr_reader :url
248
+
249
+ def initialize(socket, url)
250
+ @socket = socket
251
+ @url = url
252
+ end
253
+
254
+ def write(data)
255
+ @socket.write(data)
256
+ @socket.flush
257
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
258
+ nil
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Buffer
6
+ # Agent-side bridge that connects the local Buffer::Server to the hosted
7
+ # server over a persistent WebSocket. Commands from the server
8
+ # (enqueue_injection, idle_state, etc.) arrive as JSON text frames and are
9
+ # dispatched to the local Buffer::Server command handlers. Responses flow
10
+ # back the same way.
11
+ #
12
+ # Subclass of Superkick::Hosted::Bridge — inherits the WebSocket lifecycle,
13
+ # auth handshake, read loop, and reconnection with exponential backoff.
14
+ class Bridge < Superkick::Hosted::Bridge
15
+ def initialize(agent_id:, server_url:, api_key:, command_handler:)
16
+ super(agent_id:, server_url:, api_key:)
17
+ @command_handler = command_handler
18
+ end
19
+
20
+ private
21
+
22
+ def websocket_path
23
+ "/api/v1/agents/#{@agent_id}/buffer/ws"
24
+ end
25
+
26
+ def channel_name
27
+ "buffer:ws"
28
+ end
29
+
30
+ def handle_server_message(message)
31
+ response = @command_handler.call(message)
32
+
33
+ # If the command had a request_id, send the response back
34
+ if message[:request_id] && response
35
+ response[:request_id] = message[:request_id]
36
+ send_text(JSON.generate(response))
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end