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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Goals
5
+ # Goal that runs a shell command periodically. Exit code determines
6
+ # the returned status:
7
+ #
8
+ # exit 0 → :completed (terminal — task succeeded)
9
+ # exit 1 → :pending (keep checking)
10
+ # exit 2 → :in_progress (actively working, stored for observability)
11
+ # exit 3 → :errored (recoverable issue, stored for observability)
12
+ # exit 4 → :failed (irrecoverable — task cannot be completed)
13
+ # exit 5-125 → :errored (reserved for future use, treated as recoverable)
14
+ # exit 126+ → :failed (shell/OS errors: 126=not executable, 127=not found, 128+=signal)
15
+ #
16
+ # The command is re-run each check cycle until a terminal status is
17
+ # reached or the agent is terminated.
18
+ #
19
+ # Configuration:
20
+ # goal:
21
+ # type: command
22
+ # run: "gh pr list --head $BRANCH --json number -q '.[0].number'"
23
+ # timeout: 30 # seconds, default 30
24
+ #
25
+ # Environment variables passed to the command:
26
+ # SUPERKICK_AGENT_ID — the agent ID
27
+ # SUPERKICK_GOAL_COMPLETED — exit code for :completed (0)
28
+ # SUPERKICK_GOAL_PENDING — exit code for :pending (1)
29
+ # SUPERKICK_GOAL_IN_PROGRESS — exit code for :in_progress (2)
30
+ # SUPERKICK_GOAL_ERRORED — exit code for :errored (3)
31
+ # SUPERKICK_GOAL_FAILED — exit code for :failed (4)
32
+ #
33
+ # Use the env vars instead of literal exit codes so scripts don't
34
+ # depend on the numeric mapping:
35
+ #
36
+ # exit $SUPERKICK_GOAL_IN_PROGRESS
37
+ #
38
+ class Command < Goal
39
+ DEFAULT_TIMEOUT = 30
40
+
41
+ # Canonical exit codes — the env vars are the stable API.
42
+ EXIT_COMPLETED = 0
43
+ EXIT_PENDING = 1
44
+ EXIT_IN_PROGRESS = 2
45
+ EXIT_ERRORED = 3
46
+ EXIT_FAILED = 4
47
+
48
+ GOAL_ENV = {
49
+ "SUPERKICK_GOAL_COMPLETED" => EXIT_COMPLETED.to_s,
50
+ "SUPERKICK_GOAL_PENDING" => EXIT_PENDING.to_s,
51
+ "SUPERKICK_GOAL_IN_PROGRESS" => EXIT_IN_PROGRESS.to_s,
52
+ "SUPERKICK_GOAL_ERRORED" => EXIT_ERRORED.to_s,
53
+ "SUPERKICK_GOAL_FAILED" => EXIT_FAILED.to_s
54
+ }.freeze
55
+
56
+ def self.type = :command
57
+
58
+ def self.description
59
+ "Runs a shell command periodically. Exit code 0 means completed, " \
60
+ "1 means pending (keep checking), 4 means failed. " \
61
+ "Environment variables SUPERKICK_GOAL_* provide the exit codes."
62
+ end
63
+
64
+ def self.required_config = %i[run]
65
+
66
+ def check
67
+ cmd = config[:run]
68
+ raise ArgumentError, "Goal::Command requires a :run config key" unless cmd
69
+
70
+ timeout = config[:timeout] || DEFAULT_TIMEOUT
71
+ env = GOAL_ENV.merge("SUPERKICK_AGENT_ID" => agent_id)
72
+
73
+ result = ProcessRunner.run(cmd, timeout:, env:, chdir: Dir.pwd)
74
+
75
+ if result[:timed_out]
76
+ Superkick.logger.warn("goal:command") { "Command timed out for #{agent_id}" }
77
+ :pending
78
+ else
79
+ exit_code_to_status(result[:status].exitstatus)
80
+ end
81
+ rescue => e
82
+ Superkick.logger.error("goal:command") { "Check failed for #{agent_id}: #{e.message}" }
83
+ :pending
84
+ end
85
+
86
+ private
87
+
88
+ def exit_code_to_status(code)
89
+ case code
90
+ when EXIT_COMPLETED then :completed
91
+ when EXIT_PENDING then :pending
92
+ when EXIT_IN_PROGRESS then :in_progress
93
+ when EXIT_ERRORED then :errored
94
+ when EXIT_FAILED then :failed
95
+ when 5..125 then :errored
96
+ else :failed # 126+ (shell/OS errors)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ Superkick::Goal.register(Superkick::Goals::Command)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Thread-safe ring buffer that stores the most recent N bytes of PTY output.
5
+ # When a new attach client connects, the entire buffer is replayed so they
6
+ # see context rather than a blank screen.
7
+ class HistoryBuffer
8
+ DEFAULT_CAPACITY = 100 * 1024 # 100 KB
9
+
10
+ def initialize(capacity: DEFAULT_CAPACITY)
11
+ @capacity = capacity
12
+ @mutex = Mutex.new
13
+ @buffer = "".b
14
+ end
15
+
16
+ # Append PTY output data. Trims from the front if capacity is exceeded.
17
+ def write(data)
18
+ @mutex.synchronize do
19
+ @buffer << data.b
20
+ if @buffer.bytesize > @capacity
21
+ excess = @buffer.bytesize - @capacity
22
+ @buffer = @buffer.byteslice(excess..)
23
+ end
24
+ end
25
+ end
26
+
27
+ # Return a copy of the current buffer contents.
28
+ # @return [String] binary string of recent PTY output
29
+ def snapshot
30
+ @mutex.synchronize { @buffer.dup }
31
+ end
32
+
33
+ # Current size in bytes.
34
+ def size
35
+ @mutex.synchronize { @buffer.bytesize }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Attach
6
+ # Agent-side bridge that connects the local Attach::Server to the hosted
7
+ # server over a persistent WebSocket. Unlike the Buffer bridge, this
8
+ # channel uses binary frames for streaming PTY output.
9
+ #
10
+ # Subclass of Superkick::Hosted::Bridge — inherits the WebSocket lifecycle,
11
+ # auth handshake, read loop, and reconnection with exponential backoff.
12
+ #
13
+ # Output forwarding: registers a broadcast callback on Attach::Server so
14
+ # that PTY output is forwarded to the relay as OUTPUT frames.
15
+ #
16
+ # Remote input: binary WebSocket messages from the relay (INPUT/RESIZE
17
+ # frames from remote users) are dispatched to the Attach::Server.
18
+ class Bridge < Superkick::Hosted::Bridge
19
+ def initialize(agent_id:, server_url:, api_key:, attach_server:)
20
+ super(agent_id:, server_url:, api_key:)
21
+ @attach_server = attach_server
22
+ end
23
+
24
+ private
25
+
26
+ def websocket_path
27
+ "/api/v1/agents/#{@agent_id}/attach/agent/ws"
28
+ end
29
+
30
+ def channel_name
31
+ "attach:ws"
32
+ end
33
+
34
+ def on_authenticated
35
+ @attach_server.on_broadcast do |data|
36
+ frame = Superkick::Attach::Protocol.build_frame(Superkick::Attach::Protocol::OUTPUT, data)
37
+ send_binary(frame)
38
+ end
39
+ end
40
+
41
+ def handle_server_message(message)
42
+ # Text messages from server (besides auth/ping) are not expected
43
+ # for the attach channel — all data is binary
44
+ end
45
+
46
+ def handle_binary_message(data)
47
+ @attach_server.handle_remote_input(data)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Attach
6
+ # WebSocket transport for attach sessions.
7
+ # Connects to the hosted server, authenticates, then streams
8
+ # Attach::Protocol binary frames over WebSocket messages.
9
+ class Client < Superkick::Attach::Client
10
+ CONNECT_TIMEOUT = 10 # seconds
11
+
12
+ def self.from(agent_id:, config: Superkick.config, mode: :ro, escape_key: "\x01", force: false)
13
+ server_config = config.server
14
+ new(
15
+ server_url: server_config[:url],
16
+ api_key: server_config[:api_key],
17
+ agent_id:,
18
+ mode:, escape_key:, force:
19
+ )
20
+ end
21
+
22
+ def initialize(server_url:, api_key:, agent_id:, **session_args)
23
+ super(**session_args)
24
+ @server_url = server_url.chomp("/")
25
+ @api_key = api_key
26
+ @agent_id = agent_id
27
+ @frame_queue = Queue.new
28
+ @reader_thread = nil
29
+
30
+ connect!
31
+ end
32
+
33
+ def read_frame
34
+ data = @frame_queue.pop
35
+ return nil if data == :eof
36
+
37
+ # Parse Protocol frame from binary data
38
+ return nil if data.bytesize < 5
39
+
40
+ type = data.getbyte(0)
41
+ len = data[1, 4].unpack1("N")
42
+ payload = data[5, len]
43
+ return nil unless payload && payload.bytesize == len
44
+
45
+ [type, payload]
46
+ end
47
+
48
+ def write_frame(type, data)
49
+ frame = Superkick::Attach::Protocol.build_frame(type, data)
50
+ @driver.binary(frame)
51
+ flush
52
+ end
53
+
54
+ def write_json_frame(type, hash)
55
+ write_frame(type, JSON.generate(hash))
56
+ end
57
+
58
+ def close
59
+ @driver&.close
60
+ @socket&.close
61
+ @reader_thread&.kill
62
+ rescue IOError, Errno::EBADF
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ def connect!
69
+ uri = URI.parse("#{@server_url}/api/v1/agents/#{@agent_id}/attach/ws")
70
+ @socket = open_socket(uri)
71
+ @driver = create_driver(uri)
72
+
73
+ setup_handlers
74
+ @driver.start
75
+ flush
76
+
77
+ # Send auth
78
+ auth = {type: "auth", token: @api_key}
79
+ @driver.text(JSON.generate(auth))
80
+ flush
81
+
82
+ # Start background reader
83
+ @reader_thread = Thread.new { reader_loop }
84
+
85
+ # Wait for welcome
86
+ wait_for_welcome
87
+ end
88
+
89
+ def open_socket(uri)
90
+ default_port = (uri.scheme == "wss") ? 443 : 80
91
+ port = uri.port || default_port
92
+ tcp = TCPSocket.new(uri.host, port)
93
+
94
+ if uri.scheme == "wss"
95
+ require "openssl"
96
+ ctx = OpenSSL::SSL::SSLContext.new
97
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
98
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
99
+ ssl.hostname = uri.host
100
+ ssl.connect
101
+ ssl
102
+ else
103
+ tcp
104
+ end
105
+ end
106
+
107
+ def create_driver(uri)
108
+ require "websocket/driver"
109
+
110
+ wrapper = SocketWrapper.new(@socket, uri.to_s)
111
+ driver = WebSocket::Driver.client(wrapper)
112
+ driver.set_header("Authorization", "Bearer #{@api_key}")
113
+ driver
114
+ end
115
+
116
+ def setup_handlers
117
+ @welcome_received = false
118
+
119
+ @driver.on :message do |event|
120
+ handle_text_message(event.data)
121
+ end
122
+
123
+ @driver.on :binary do |event|
124
+ @frame_queue << event.data.b
125
+ end
126
+
127
+ @driver.on :close do
128
+ @frame_queue << :eof
129
+ end
130
+
131
+ @driver.on :error do |event|
132
+ Superkick.logger.error("attach:client:hosted") { "WebSocket error: #{event.message}" }
133
+ @frame_queue << :eof
134
+ end
135
+ end
136
+
137
+ def handle_text_message(data)
138
+ message = JSON.parse(data, symbolize_names: true)
139
+
140
+ case message[:type]
141
+ when "welcome"
142
+ @welcome_received = true
143
+ when "error"
144
+ Superkick.logger.error("attach:client:hosted") { "Server error: #{message[:message]}" }
145
+ @frame_queue << :eof
146
+ when "ping"
147
+ @driver.text(JSON.generate({type: "pong"}))
148
+ flush
149
+ end
150
+ rescue JSON::ParserError
151
+ nil
152
+ end
153
+
154
+ def reader_loop
155
+ loop do
156
+ data = begin
157
+ @socket.read_nonblock(4096)
158
+ rescue IO::WaitReadable
159
+ IO.select([@socket], nil, nil, 30)
160
+ retry
161
+ rescue IOError, Errno::ECONNRESET, Errno::EBADF
162
+ break
163
+ end
164
+
165
+ break unless data && !data.empty?
166
+
167
+ @driver.parse(data)
168
+ flush
169
+ end
170
+
171
+ @frame_queue << :eof
172
+ end
173
+
174
+ def wait_for_welcome
175
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + CONNECT_TIMEOUT
176
+ until @welcome_received
177
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
178
+ raise Errno::ETIMEDOUT, "WebSocket auth timeout" if remaining <= 0
179
+ sleep 0.05
180
+ end
181
+ end
182
+
183
+ def flush
184
+ # websocket-driver writes directly via SocketWrapper
185
+ end
186
+
187
+ # Minimal wrapper for websocket-driver.
188
+ class SocketWrapper
189
+ attr_reader :url
190
+
191
+ def initialize(socket, url)
192
+ @socket = socket
193
+ @url = url
194
+ end
195
+
196
+ def write(data)
197
+ @socket.write(data)
198
+ @socket.flush
199
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
200
+ nil
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ Superkick::Attach.register_client(:hosted, Superkick::Hosted::Attach::Client)