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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Buffer
6
+ # WebSocket client for sending buffer commands to remote agents.
7
+ #
8
+ # In hosted mode, agents maintain persistent WebSocket connections to the
9
+ # server. The server holds a Relay per agent. This client sends
10
+ # commands through those relays.
11
+ #
12
+ # This is a server-side component — it routes commands to agents via
13
+ # their WebSocket connections, not over Unix sockets.
14
+ class Client < Superkick::Buffer::Client
15
+ def self.from(relay_store:, **_kwargs)
16
+ new(relay_store:)
17
+ end
18
+
19
+ def initialize(relay_store:)
20
+ @relay_store = relay_store
21
+ end
22
+
23
+ # Send a command to the agent via its WebSocket relay and return the response.
24
+ def request(agent_id, command, **params)
25
+ relay = resolve_relay(agent_id)
26
+ relay.request(command, **params)
27
+ end
28
+
29
+ # Fire-and-forget: send a command through the relay without waiting
30
+ # for a response. Overrides the base class to use the relay's
31
+ # fire-and-forget method instead of the request/response path.
32
+ def send_command(agent_id, command, **params)
33
+ relay = resolve_relay(agent_id)
34
+ relay.send_command(command, **params)
35
+ rescue AgentUnreachable => e
36
+ Superkick.logger.debug(log_tag) { "Send failed for #{agent_id}: #{e.message}" }
37
+ nil
38
+ end
39
+
40
+ # Check if the agent has an active WebSocket relay connection.
41
+ def reachable?(agent_id)
42
+ relay = @relay_store&.get(agent_id)
43
+ relay&.connected? || false
44
+ end
45
+
46
+ private
47
+
48
+ def log_tag
49
+ "buffer:hosted"
50
+ end
51
+
52
+ def resolve_relay(agent_id)
53
+ relay = @relay_store&.get(agent_id)
54
+ raise AgentUnreachable, "No relay for agent #{agent_id}" unless relay&.connected?
55
+
56
+ relay
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ Superkick::Buffer.register_client(:hosted, Superkick::Hosted::Buffer::Client)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Superkick
6
+ module Hosted
7
+ module Buffer
8
+ # Server-side relay that bridges the Injector/Supervisor to a remote agent
9
+ # over a persistent raw WebSocket connection (not ActionCable).
10
+ #
11
+ # In hosted mode, the agent opens a raw WebSocket to the server and
12
+ # authenticates via the shared auth protocol (see Hosted::Bridge). The
13
+ # server creates a Relay for each connected agent. Server-side components
14
+ # send buffer commands through the Relay, which forwards them as JSON
15
+ # text frames and (for request/response commands) waits for the reply.
16
+ #
17
+ # The Relay is transport-agnostic — it sends/receives JSON hashes through
18
+ # a websocket object that responds to #send(String).
19
+ class Relay
20
+ RESPONSE_TIMEOUT = 5 # seconds
21
+
22
+ def initialize(agent_id:)
23
+ @agent_id = agent_id
24
+ @websocket = nil
25
+ @mutex = Mutex.new
26
+ @pending = {}
27
+ @pending_mutex = Mutex.new
28
+ end
29
+
30
+ # Called by the WebSocket handler when an agent connects.
31
+ def attach(websocket)
32
+ @mutex.synchronize { @websocket = websocket }
33
+ Superkick.logger.info("buffer:relay") { "Agent #{@agent_id} attached" }
34
+ end
35
+
36
+ # Called when the WebSocket connection closes.
37
+ def detach
38
+ @mutex.synchronize { @websocket = nil }
39
+
40
+ # Wake up any pending request/response waiters
41
+ @pending_mutex.synchronize do
42
+ @pending.each_value { it[:condition]&.broadcast }
43
+ end
44
+
45
+ Superkick.logger.info("buffer:relay") { "Agent #{@agent_id} detached" }
46
+ end
47
+
48
+ def connected?
49
+ @mutex.synchronize { !@websocket.nil? }
50
+ end
51
+
52
+ # Send a command and wait for the response (request/response pattern).
53
+ def request(command, **params)
54
+ ws = @mutex.synchronize { @websocket }
55
+ raise Superkick::Buffer::Client::AgentUnreachable, "Agent #{@agent_id} not connected" unless ws
56
+
57
+ request_id = SecureRandom.uuid
58
+ message = {command:, request_id:, **params}
59
+
60
+ # Register a pending response slot
61
+ entry = {response: nil, condition: ConditionVariable.new, mutex: Mutex.new}
62
+ @pending_mutex.synchronize { @pending[request_id] = entry }
63
+
64
+ begin
65
+ ws.send(JSON.generate(message))
66
+
67
+ # Wait for the response with timeout
68
+ entry[:mutex].synchronize do
69
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + RESPONSE_TIMEOUT
70
+ while entry[:response].nil?
71
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ break if remaining <= 0
73
+ entry[:condition].wait(entry[:mutex], remaining)
74
+ end
75
+ end
76
+
77
+ entry[:response] || raise(Superkick::Buffer::Client::AgentUnreachable, "Response timeout for #{@agent_id}")
78
+ ensure
79
+ @pending_mutex.synchronize { @pending.delete(request_id) }
80
+ end
81
+ end
82
+
83
+ # Fire-and-forget: send a command without waiting for a response.
84
+ def send_command(command, **params)
85
+ ws = @mutex.synchronize { @websocket }
86
+ raise Superkick::Buffer::Client::AgentUnreachable, "Agent #{@agent_id} not connected" unless ws
87
+
88
+ message = {command:, **params}
89
+ ws.send(JSON.generate(message))
90
+ {ok: true}
91
+ end
92
+
93
+ # Called when the agent sends a message back over the WebSocket.
94
+ # Routes responses to pending request/response waiters, and handles
95
+ # unsolicited messages (e.g. injection_result IPC).
96
+ def handle_message(message)
97
+ request_id = message[:request_id]
98
+
99
+ if request_id
100
+ entry = @pending_mutex.synchronize { @pending[request_id] }
101
+ if entry
102
+ entry[:mutex].synchronize do
103
+ entry[:response] = message
104
+ entry[:condition].broadcast
105
+ end
106
+ return
107
+ end
108
+ end
109
+
110
+ # Unsolicited message from agent (e.g. injection_result)
111
+ handle_agent_event(message)
112
+ end
113
+
114
+ private
115
+
116
+ # Handle unsolicited agent-to-server messages.
117
+ # In hosted mode, injection_result and report_cost are sent back over
118
+ # the WebSocket rather than opening separate control connections.
119
+ def handle_agent_event(message)
120
+ Superkick.logger.debug("buffer:relay") { "Agent event from #{@agent_id}: #{message[:command]}" }
121
+ # Future: route to control server command handlers
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Hosted
5
+ module Buffer
6
+ # Thread-safe registry of Hosted::Buffer::Relay instances, one per connected agent.
7
+ # Created at server startup in hosted mode and passed to Buffer::Client.
8
+ class RelayStore
9
+ def initialize
10
+ @relays = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ # Get or create a relay for the given agent.
15
+ def get_or_create(agent_id)
16
+ @mutex.synchronize do
17
+ @relays[agent_id] ||= Relay.new(agent_id:)
18
+ end
19
+ end
20
+
21
+ # Get an existing relay (nil if none).
22
+ def get(agent_id)
23
+ @mutex.synchronize { @relays[agent_id] }
24
+ end
25
+
26
+ # Remove a relay when the agent disconnects permanently.
27
+ def remove(agent_id)
28
+ @mutex.synchronize { @relays.delete(agent_id) }
29
+ end
30
+
31
+ # Iterate over all relays.
32
+ def each(&block)
33
+ @mutex.synchronize { @relays.each_value(&block) }
34
+ end
35
+
36
+ def size
37
+ @mutex.synchronize { @relays.size }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Superkick
6
+ module Hosted
7
+ module Control
8
+ # HTTPS client for communicating with a remote Superkick control server.
9
+ # Implements the same interface as Superkick::Control::Client so callers
10
+ # are transport-agnostic.
11
+ #
12
+ # All control commands go through POST /api/v1/control with the command
13
+ # name in the JSON body — no per-command HTTP routes.
14
+ class Client < Superkick::Control::Client
15
+ TIMEOUT = 10 # seconds
16
+
17
+ def self.from(config: Superkick.config, connection: nil, **_kwargs)
18
+ server = config.server
19
+ new(
20
+ url: server[:url],
21
+ api_key: server[:api_key],
22
+ connection:
23
+ )
24
+ end
25
+
26
+ # @param url [String] base URL of the hosted server
27
+ # @param api_key [String] bearer token for authentication
28
+ # @param connection [Faraday::Connection, nil] optional pre-built connection (for testing)
29
+ def initialize(url:, api_key:, connection: nil)
30
+ raise ArgumentError, "url is required for hosted transport" unless url
31
+ raise ArgumentError, "api_key is required for hosted transport" unless api_key
32
+
33
+ @url = url.chomp("/")
34
+ @api_key = api_key
35
+ @connection = connection || build_connection
36
+ end
37
+
38
+ # Send a command to the hosted server and return the wrapped response.
39
+ # @param command [String] control command name
40
+ # @param params [Hash] extra key/value params merged into the request
41
+ # @return [Control::Reply] wrapped response from the server
42
+ def request(command, **params)
43
+ response = @connection.post("/api/v1/control", {command:, **params})
44
+
45
+ case response.status
46
+ when 200 then Superkick::Control::Reply.new(response.body)
47
+ when 401 then raise AuthenticationError, "Authentication failed — check your API key"
48
+ else raise ServerUnavailable, "Server returned HTTP #{response.status}"
49
+ end
50
+ rescue Faraday::ConnectionFailed => e
51
+ raise ServerUnavailable, "Connection failed: #{e.message}"
52
+ rescue Faraday::TimeoutError => e
53
+ raise ServerUnavailable, "Request timed out: #{e.message}"
54
+ end
55
+
56
+ # Returns true if the hosted server is reachable and authenticated.
57
+ def alive?
58
+ request("ping").success?
59
+ rescue ServerUnavailable
60
+ false
61
+ end
62
+
63
+ # Close the underlying Faraday connection.
64
+ def close
65
+ @connection.close
66
+ end
67
+
68
+ private
69
+
70
+ def build_connection
71
+ Faraday.new(url: @url) do |f|
72
+ f.request :authorization, "Bearer", @api_key
73
+ f.request :json
74
+ f.response :json, parser_options: {symbolize_names: true}
75
+ f.options.timeout = TIMEOUT
76
+ f.options.open_timeout = TIMEOUT
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ Superkick::Control.register_client(:hosted, Superkick::Hosted::Control::Client)
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Superkick
7
+ module Hosted
8
+ # Transparent MCP stdio-to-HTTP proxy for hosted mode.
9
+ #
10
+ # Runs as a local stdio subprocess (same as McpServer) but instead of
11
+ # handling tool calls locally, forwards all JSON-RPC messages to the
12
+ # hosted server's Streamable HTTP MCP endpoint. Stamps the agent identity
13
+ # header on every request so the hosted server knows which agent is calling.
14
+ #
15
+ # The AI CLI sees a normal stdio MCP server — it has no idea it's talking
16
+ # to a remote endpoint. This avoids teaching every driver about remote MCP
17
+ # URL configuration.
18
+ #
19
+ # Protocol flow:
20
+ # AI CLI → stdin (JSON-RPC) → McpProxy → POST /api/v1/mcp → Hosted Server
21
+ # Hosted Server → HTTP response → McpProxy → stdout (JSON-RPC) → AI CLI
22
+ #
23
+ # Streamable HTTP responses may be:
24
+ # - application/json — single JSON-RPC response (written directly to stdout)
25
+ # - text/event-stream — SSE stream of JSON-RPC messages (each event written to stdout)
26
+ # - 202 Accepted — notification acknowledged, no response body
27
+ class McpProxy
28
+ TIMEOUT = 30 # seconds — tool calls may take a while
29
+
30
+ def self.start
31
+ new.run
32
+ end
33
+
34
+ # @param connection [Faraday::Connection, nil] optional pre-built connection (for testing)
35
+ def initialize(connection: nil)
36
+ @agent_id = ENV["SUPERKICK_AGENT_ID"]
37
+ raise "SUPERKICK_AGENT_ID must be set. The MCP proxy should be started via `superkick agent`." unless @agent_id
38
+
39
+ server_config = Superkick.config.server
40
+ @url = server_config[:url]
41
+ @api_key = server_config[:api_key]
42
+ raise "server.url is required for hosted MCP proxy" unless @url
43
+ raise "server.api_key is required for hosted MCP proxy" unless @api_key
44
+
45
+ @url = @url.chomp("/")
46
+ @connection = connection || build_connection
47
+ @session_id = nil
48
+ end
49
+
50
+ def run
51
+ $stdin.each_line do |line|
52
+ line = line.strip
53
+ next if line.empty?
54
+
55
+ forward(line)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def forward(json_body)
62
+ headers = {"Content-Type" => "application/json", "Accept" => "application/json, text/event-stream"}
63
+ headers["Mcp-Session-Id"] = @session_id if @session_id
64
+
65
+ response = @connection.post("/api/v1/mcp", json_body, headers)
66
+
67
+ # Track session ID from server
68
+ if response.headers["mcp-session-id"]
69
+ @session_id = response.headers["mcp-session-id"]
70
+ end
71
+
72
+ case response.status
73
+ when 200
74
+ content_type = response.headers["content-type"].to_s
75
+ if content_type.include?("text/event-stream")
76
+ write_sse_events(response.body)
77
+ else
78
+ write_line(response.body)
79
+ end
80
+ when 202
81
+ # Notification accepted — no response to write
82
+ nil
83
+ else
84
+ # Return a JSON-RPC error for the request if we can extract the id
85
+ write_jsonrpc_error(json_body, response)
86
+ end
87
+ rescue Faraday::ConnectionFailed => e
88
+ write_jsonrpc_error(json_body, nil, message: "Connection failed: #{e.message}")
89
+ rescue Faraday::TimeoutError => e
90
+ write_jsonrpc_error(json_body, nil, message: "Request timed out: #{e.message}")
91
+ end
92
+
93
+ # Parse SSE stream and write each "message" event as a line to stdout.
94
+ def write_sse_events(body)
95
+ event_type = nil
96
+ body.each_line do |line|
97
+ line = line.rstrip
98
+ if line.start_with?("event:")
99
+ event_type = line.sub("event:", "").strip
100
+ elsif line.start_with?("data:")
101
+ data = line.sub("data:", "").strip
102
+ write_line(data) if event_type == "message" && !data.empty?
103
+ elsif line.empty?
104
+ event_type = nil
105
+ end
106
+ end
107
+ end
108
+
109
+ def write_line(data)
110
+ $stdout.write(data)
111
+ $stdout.write("\n")
112
+ $stdout.flush
113
+ end
114
+
115
+ # Best-effort JSON-RPC error response when the hosted server fails.
116
+ # Only writes if the original message was a request (has an "id" field).
117
+ def write_jsonrpc_error(json_body, response, message: nil)
118
+ parsed = begin
119
+ JSON.parse(json_body)
120
+ rescue JSON::ParserError
121
+ nil
122
+ end
123
+ return unless parsed.is_a?(Hash) && parsed.key?("id")
124
+
125
+ error_message = message || "Hosted server returned HTTP #{response&.status}"
126
+ error_response = {
127
+ "jsonrpc" => "2.0",
128
+ "id" => parsed["id"],
129
+ "error" => {"code" => -32603, "message" => error_message}
130
+ }
131
+ write_line(JSON.generate(error_response))
132
+ end
133
+
134
+ def build_connection
135
+ Faraday.new(url: @url) do |f|
136
+ f.request :authorization, "Bearer", @api_key
137
+ f.headers["X-Superkick-Agent-Id"] = @agent_id
138
+ f.options.timeout = TIMEOUT
139
+ f.options.open_timeout = TIMEOUT
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # InjectHandler — dispatches monitor events to the Injector.
5
+ #
6
+ # Implements the handler contract (handle/flush_pending) used by Monitor.
7
+ # The Injector sends events to the agent's InjectionQueue (fire-and-forget).
8
+ # Pending event management is handled agent-side by the InjectionQueue.
9
+ class InjectHandler
10
+ def initialize(injector:, agent:)
11
+ @injector = injector
12
+ @agent = agent
13
+ end
14
+
15
+ # Handle a single event. Returns :enqueued, :skipped, or :error.
16
+ def handle(event:)
17
+ @injector.inject(agent_id: @agent.id, event:)
18
+ end
19
+
20
+ # No-op — pending events are managed agent-side by the InjectionQueue.
21
+ def flush_pending
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # PatternGuard matches pty output lines and signals the InputBuffer to
5
+ # suppress injection while the guard is active.
6
+ #
7
+ # name — unique Symbol used as the hash key in InputBuffer#guards
8
+ # pattern — Regexp matched against ANSI-stripped output
9
+ # reason — human-readable explanation shown in superkick status
10
+ # clear_on_submit — if true, the guard is cleared when the user hits Enter/C-c
11
+ PatternGuard = Data.define(:name, :pattern, :reason, :clear_on_submit) do
12
+ def initialize(name:, pattern:, reason:, clear_on_submit: true)
13
+ super
14
+ end
15
+
16
+ # Match against text that has already been stripped of ANSI codes.
17
+ def match?(stripped_text)
18
+ pattern.match?(stripped_text)
19
+ end
20
+ end
21
+
22
+ # Factory helper so callers don't need to remember keyword names.
23
+ def self.pattern_guard(name, pattern, reason, clear_on_submit: true)
24
+ PatternGuard.new(name:, pattern:, reason:, clear_on_submit:)
25
+ end
26
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Agent-side injection queue with TTL, supersede, and priority semantics.
5
+ #
6
+ # The server sends injection requests (rendered prompts) to the agent via
7
+ # `enqueue_injection`. The queue drains locally, checking preconditions
8
+ # (idle state, guards) with zero network latency before writing to the PTY.
9
+ #
10
+ # Queue semantics:
11
+ # - TTL: Stale injections are dropped silently after their TTL expires.
12
+ # - Supersede: A new injection with the same supersede_key replaces any
13
+ # existing queued entry with that key.
14
+ # - Priority: :high (spawn kickoffs, team messages), :normal (monitor
15
+ # events), :low (cost polling). Higher priority drains first.
16
+ # - Max size: Oldest low-priority entries are dropped when the queue is full.
17
+ class InjectionQueue
18
+ Entry = Data.define(:id, :prompt, :monitor_type, :monitor_name,
19
+ :priority, :enqueued_at, :ttl, :supersede_key)
20
+
21
+ PRIORITIES = {high: 0, normal: 1, low: 2}.freeze
22
+ DEFAULT_TTL = 300 # seconds
23
+ DEFAULT_DRAIN_INTERVAL = 0.5 # seconds
24
+ MAX_SIZE = 50
25
+
26
+ attr_reader :entries
27
+
28
+ def initialize(pty_proxy:, idle_threshold:, inject_clear_delay:, control_client: nil,
29
+ drain_interval: DEFAULT_DRAIN_INTERVAL)
30
+ @pty_proxy = pty_proxy
31
+ @idle_threshold = idle_threshold
32
+ @inject_clear_delay = inject_clear_delay
33
+ @control_client = control_client
34
+ @drain_interval = drain_interval
35
+ @entries = []
36
+ @mutex = Mutex.new
37
+ @drain_thread = nil
38
+ end
39
+
40
+ # Enqueue an injection request. Supersedes existing entries with matching key.
41
+ # Returns the Entry or nil if rejected.
42
+ def enqueue(id:, prompt:, monitor_type: nil, monitor_name: nil,
43
+ priority: :normal, ttl: DEFAULT_TTL, supersede_key: nil)
44
+ key = supersede_key || monitor_name.to_s
45
+ pri = PRIORITIES.fetch(priority.to_sym, PRIORITIES[:normal])
46
+
47
+ entry = Entry.new(
48
+ id:, prompt:, monitor_type:, monitor_name:,
49
+ priority: pri,
50
+ enqueued_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
51
+ ttl:, supersede_key: key
52
+ )
53
+
54
+ @mutex.synchronize do
55
+ # Supersede: remove existing entries with the same key
56
+ superseded = @entries.select { it.supersede_key == key }
57
+ @entries.reject! { it.supersede_key == key }
58
+ superseded.each { report_result(it.id, :superseded) }
59
+
60
+ @entries << entry
61
+ drop_oldest_if_full
62
+ end
63
+
64
+ entry
65
+ end
66
+
67
+ def start
68
+ @drain_thread = Thread.new { drain_loop }
69
+ self
70
+ end
71
+
72
+ def stop
73
+ @drain_thread&.kill
74
+ @drain_thread&.join(2)
75
+ @drain_thread = nil
76
+ end
77
+
78
+ def size
79
+ @mutex.synchronize { @entries.size }
80
+ end
81
+
82
+ private
83
+
84
+ def drain_loop
85
+ loop do
86
+ entry = next_ready_entry
87
+ if entry
88
+ attempt_injection(entry)
89
+ else
90
+ sleep(@drain_interval)
91
+ end
92
+ end
93
+ rescue => e
94
+ Superkick.logger.error("injection_queue") { "Drain loop error: #{e.message}" }
95
+ retry
96
+ end
97
+
98
+ def next_ready_entry
99
+ @mutex.synchronize do
100
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+
102
+ # Remove expired entries
103
+ expired, @entries = @entries.partition { (now - it.enqueued_at) > it.ttl }
104
+ expired.each { report_result(it.id, :expired) }
105
+
106
+ # Sort by priority (lower number = higher priority), then by age (oldest first)
107
+ @entries.sort_by! { [it.priority, it.enqueued_at] }
108
+ @entries.first
109
+ end
110
+ end
111
+
112
+ def attempt_injection(entry)
113
+ # All checks are LOCAL — no network calls
114
+ idle = @pty_proxy.idle_state
115
+ seconds_idle = idle[:seconds_idle]
116
+ at_prompt = idle[:at_prompt]
117
+
118
+ threshold = @idle_threshold
119
+
120
+ unless seconds_idle && seconds_idle >= threshold
121
+ sleep(@drain_interval)
122
+ return
123
+ end
124
+
125
+ # Conservative: if not at prompt, require 2× threshold
126
+ unless at_prompt || seconds_idle >= threshold * 2
127
+ sleep(@drain_interval)
128
+ return
129
+ end
130
+
131
+ unless @pty_proxy.guards_clear?
132
+ sleep(@drain_interval)
133
+ return
134
+ end
135
+
136
+ # Remove from queue
137
+ @mutex.synchronize { @entries.delete(entry) }
138
+
139
+ # Execute injection sequence (same as the old Injector)
140
+ partial_input = @pty_proxy.partial_input
141
+ delay = @inject_clear_delay
142
+
143
+ @pty_proxy.enqueue_inject("\x03")
144
+ sleep(delay)
145
+ @pty_proxy.enqueue_inject("\r")
146
+ sleep(delay)
147
+ @pty_proxy.enqueue_inject("#{entry.prompt}\r")
148
+ sleep(delay)
149
+ @pty_proxy.enqueue_inject(partial_input) unless partial_input.empty?
150
+
151
+ Superkick.logger.info("injection_queue") { "Injected #{entry.monitor_type}:#{entry.id}" }
152
+ report_result(entry.id, :injected)
153
+ end
154
+
155
+ def report_result(id, status)
156
+ return unless @control_client
157
+
158
+ Thread.new do
159
+ @control_client.request("injection_result", id:, status: status.to_s, agent_id: @pty_proxy.agent_id)
160
+ rescue => e
161
+ Superkick.logger.debug("injection_queue") { "Result report failed: #{e.message}" }
162
+ end
163
+ end
164
+
165
+ def drop_oldest_if_full
166
+ return if @entries.size <= MAX_SIZE
167
+
168
+ # Sort so lowest-priority (highest number), oldest entries come first
169
+ @entries.sort_by! { [-it.priority, it.enqueued_at] }
170
+ dropped = @entries.shift(@entries.size - MAX_SIZE)
171
+ dropped.each { report_result(it.id, :dropped) }
172
+
173
+ # Re-sort for drain order
174
+ @entries.sort_by! { [it.priority, it.enqueued_at] }
175
+ end
176
+ end
177
+ end