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,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+ require "securerandom"
6
+
7
+ module Superkick
8
+ # PtyProxy wraps an AI coding CLI in a pseudo-terminal so Superkick controls
9
+ # the terminal directly. Three internal threads handle:
10
+ # 1. stdin proxy — reads raw keystrokes, feeds InputBuffer, forwards to pty
11
+ # 2. output proxy — reads CLI output, writes to user stdout, tracks idle state
12
+ # 3. inject queue — drains @inject_queue, writes bytes to pty master
13
+ #
14
+ # An InjectionQueue drains rendered prompts from the server, checking
15
+ # preconditions (idle state, guards) locally before writing to the PTY.
16
+ #
17
+ # On startup it creates a Buffer::Server and registers both the agent and the
18
+ # buffer socket with the server (if running). In hosted mode, it also opens
19
+ # a persistent WebSocket to the server for receiving injection commands.
20
+ class PtyProxy
21
+ attr_reader :agent_id
22
+
23
+ def initialize(command:, idle_threshold:, inject_clear_delay:, args: [], agent_id: nil,
24
+ headless: false, team_id: nil, role: nil, team_log: true)
25
+ @command = command
26
+ @args = args
27
+ agent_id ||= "agent-#{SecureRandom.hex(4)}"
28
+ @agent_id = agent_id.to_s
29
+ @headless = headless
30
+ @team_id = team_id
31
+ @role = role
32
+ @team_log = team_log
33
+
34
+ @input_buffer = InputBuffer.new
35
+ @inject_queue = Queue.new
36
+ @output_mutex = Mutex.new
37
+ @last_output_at = nil
38
+ @at_prompt = false
39
+
40
+ @control_client = Control.client_from
41
+
42
+ @injection_queue = InjectionQueue.new(
43
+ pty_proxy: self, idle_threshold:, inject_clear_delay:, control_client: @control_client
44
+ )
45
+
46
+ @buffer_server = Buffer::Server.new(
47
+ agent_id: @agent_id,
48
+ input_buffer: @input_buffer,
49
+ pty_proxy: self,
50
+ injection_queue: @injection_queue
51
+ )
52
+ @buffer_bridge = nil
53
+ @attach_server = Attach::Server.new(
54
+ agent_id: @agent_id,
55
+ pty_proxy: self,
56
+ control_client: @control_client
57
+ )
58
+ @output_logger = OutputLogger.new(agent_id: @agent_id)
59
+ @session_recorder = if Superkick.config.session_recording_enabled
60
+ build_session_recorder
61
+ end
62
+ end
63
+
64
+ # Blocking — returns when the CLI process exits.
65
+ def run
66
+ @buffer_server.start
67
+ @injection_queue.start
68
+ @attach_server.start
69
+ @output_logger.start
70
+ @session_recorder&.start
71
+ auto_register
72
+ notify_server(:register_buffer)
73
+ notify_server(:register_attach)
74
+ start_buffer_bridge
75
+ start_attach_bridge
76
+
77
+ PTY.spawn({"SUPERKICK_AGENT_ID" => @agent_id}, @command, *@args) do |pty_out, pty_in, pid|
78
+ @pty_pid = pid
79
+ @pty_in = pty_in
80
+
81
+ unless @headless
82
+ # Match window size
83
+ rows, cols = begin
84
+ $stdout.winsize
85
+ rescue Errno::ENOTTY, Errno::EIO, IOError
86
+ [24, 80]
87
+ end
88
+ begin
89
+ pty_in.winsize = [rows, cols]
90
+ rescue Errno::ENOTTY, Errno::EIO, IOError
91
+ nil
92
+ end
93
+
94
+ Signal.trap("WINCH") do
95
+ r, c = begin
96
+ $stdout.winsize
97
+ rescue Errno::ENOTTY, Errno::EIO, IOError
98
+ [24, 80]
99
+ end
100
+ begin
101
+ pty_in.winsize = [r, c]
102
+ rescue Errno::ENOTTY, Errno::EIO, IOError
103
+ nil
104
+ end
105
+ @session_recorder&.resize(c, r)
106
+ end
107
+ end
108
+
109
+ threads = []
110
+ threads << Thread.new { proxy_stdin(pty_in) } unless @headless
111
+ threads << Thread.new { proxy_output(pty_out) }
112
+ threads << Thread.new { drain_inject_queue(pty_in) }
113
+
114
+ Process.wait(pid)
115
+ threads.each(&:kill)
116
+ end
117
+ rescue PTY::ChildExited
118
+ # Normal exit
119
+ ensure
120
+ @attach_bridge&.stop
121
+ @buffer_bridge&.stop
122
+ @injection_queue.stop
123
+ @attach_server.stop
124
+ @buffer_server.stop
125
+ @session_recorder&.close
126
+ @output_logger.close
127
+ notify_server(:unregister_attach)
128
+ notify_server(:unregister_buffer)
129
+ notify_server(:unregister)
130
+ @control_client.close
131
+ end
132
+
133
+ # Called by Buffer::Server on `idle_state` command, and by InjectionQueue.
134
+ def idle_state
135
+ @output_mutex.synchronize do
136
+ seconds_idle = @last_output_at ? (Time.now.to_f - @last_output_at) : nil
137
+ {seconds_idle:, at_prompt: @at_prompt}
138
+ end
139
+ end
140
+
141
+ # Called by Buffer::Server on `inject` command.
142
+ def enqueue_inject(bytes)
143
+ @inject_queue << bytes
144
+ end
145
+
146
+ # Called by InjectionQueue to check if guards are clear.
147
+ def guards_clear?
148
+ @input_buffer.guards.empty?
149
+ end
150
+
151
+ # Called by InjectionQueue to read partial user input for restoration.
152
+ def partial_input
153
+ @input_buffer.contents
154
+ end
155
+
156
+ # Called by Attach::Server when a read-write client sends a resize frame.
157
+ def resize(rows, cols)
158
+ return unless @pty_in
159
+
160
+ begin
161
+ @pty_in.winsize = [rows, cols]
162
+ rescue Errno::ENOTTY, Errno::EIO, IOError
163
+ nil
164
+ end
165
+ @session_recorder&.resize(cols, rows)
166
+ end
167
+
168
+ private
169
+
170
+ # ── Thread bodies ───────────────────────────────────────────────────────
171
+
172
+ def proxy_stdin(pty_in)
173
+ $stdin.raw do
174
+ loop do
175
+ chunk = $stdin.readpartial(4096)
176
+ @input_buffer.append(chunk)
177
+ pty_in.write(chunk)
178
+ pty_in.flush
179
+ @session_recorder&.record_input(chunk)
180
+ end
181
+ end
182
+ rescue IOError, Errno::EIO
183
+ # stdin closed
184
+ end
185
+
186
+ def proxy_output(pty_out)
187
+ loop do
188
+ begin
189
+ data = pty_out.read_nonblock(4096)
190
+ rescue IO::WaitReadable
191
+ IO.select([pty_out])
192
+ retry
193
+ end
194
+
195
+ unless @headless
196
+ $stdout.write(data)
197
+ $stdout.flush
198
+ end
199
+ @output_logger.write(data)
200
+ @session_recorder&.record_output(data)
201
+ @attach_server.broadcast(data)
202
+
203
+ stripped = strip_ansi(data)
204
+
205
+ @output_mutex.synchronize do
206
+ @last_output_at = Time.now.to_f
207
+ @at_prompt = prompt_match?(stripped)
208
+ update_guards(stripped)
209
+ end
210
+
211
+ # Cost extraction — runs outside the output mutex
212
+ extract_cost(stripped)
213
+ end
214
+ rescue IOError, Errno::EIO
215
+ # pty closed (CLI exited)
216
+ end
217
+
218
+ def drain_inject_queue(pty_in)
219
+ loop do
220
+ bytes = @inject_queue.pop
221
+ pty_in.write(bytes)
222
+ pty_in.flush
223
+ @session_recorder&.record_input(bytes)
224
+ end
225
+ rescue IOError, Errno::EIO
226
+ # pty closed
227
+ end
228
+
229
+ # ── Helpers ──────────────────────────────────────────────────────────
230
+
231
+ ANSI_CSI = /\e\[[0-9;]*[A-Za-z]/
232
+ ANSI_OSC = /\e\][^\a]*(?:\a|\e\\)/
233
+ ANSI_MISC = /\e./
234
+
235
+ def strip_ansi(text)
236
+ text.gsub(ANSI_CSI, "").gsub(ANSI_OSC, "").gsub(ANSI_MISC, "")
237
+ end
238
+
239
+ def prompt_match?(stripped_text)
240
+ return false unless Superkick.driver
241
+
242
+ patterns = Superkick.driver.prompt_patterns
243
+ return false if patterns.empty?
244
+
245
+ patterns.any? { it.match?(stripped_text) }
246
+ end
247
+
248
+ def update_guards(stripped_text)
249
+ return unless Superkick.driver
250
+
251
+ Superkick.driver.injection_guards.each do |guard|
252
+ if guard.match?(stripped_text)
253
+ @input_buffer.set_guard(guard.name, guard.reason,
254
+ clear_on_submit: guard.clear_on_submit)
255
+ end
256
+ end
257
+ end
258
+
259
+ def extract_cost(stripped_text)
260
+ return unless Superkick.driver
261
+
262
+ patterns = Superkick.driver.cost_patterns
263
+ return if patterns.empty?
264
+
265
+ @cost_extractor ||= CostExtractor.new(patterns:)
266
+ deltas = @cost_extractor.feed(stripped_text)
267
+ deltas.each { report_cost(it) }
268
+ end
269
+
270
+ def report_cost(data)
271
+ @control_client.request("report_cost",
272
+ agent_id: @agent_id,
273
+ **data,
274
+ source: :pty_scrape)
275
+ rescue Control::Client::ServerUnavailable
276
+ # Cost tracking is best-effort
277
+ end
278
+
279
+ def notify_server(command, **extra)
280
+ extras = extra
281
+ if command == :register_buffer
282
+ extras[:output_log_path] = @output_logger.path
283
+ extras[:recording_path] = @session_recorder.path if @session_recorder
284
+ end
285
+ @control_client.request(command.to_s, agent_id: @agent_id, **extras)
286
+ rescue Control::Client::ServerUnavailable
287
+ # Server not required for `superkick agent` to work
288
+ end
289
+
290
+ def auto_register
291
+ params = {agent_id: @agent_id, working_dir: Dir.pwd}
292
+ params[:team_id] = @team_id if @team_id
293
+ params[:role] = @role if @role
294
+ params[:team_log] = @team_log
295
+ result = @control_client.request("register", **params)
296
+
297
+ # Multi-step handshake: server responds with environment_request
298
+ if result.success? && result[:environment_request]
299
+ executor = EnvironmentExecutor.new(working_dir: Dir.pwd)
300
+ environment = executor.execute(result[:environment_request])
301
+ @control_client.request("register_environment",
302
+ agent_id: @agent_id, environment:)
303
+ end
304
+ rescue Control::Client::ServerUnavailable
305
+ Superkick.logger.info("pty:#{@agent_id}") { "Server not running — agent not registered" }
306
+ end
307
+
308
+ def build_session_recorder
309
+ store = if Superkick.config.server_type == :hosted
310
+ server_config = Superkick.config.server
311
+ SessionRecorder::Store::Http.new(
312
+ server_url: server_config[:url],
313
+ api_key: server_config[:api_key]
314
+ )
315
+ end
316
+
317
+ rows, cols = begin
318
+ $stdout.winsize
319
+ rescue Errno::ENOTTY, Errno::EIO, IOError
320
+ [40, 120]
321
+ end
322
+
323
+ SessionRecorder.new(
324
+ agent_id: @agent_id,
325
+ width: cols,
326
+ height: rows,
327
+ max_size: Superkick.config.session_recording_max_size,
328
+ store:
329
+ )
330
+ end
331
+
332
+ # In hosted mode, start the bridge that connects the local Buffer::Server
333
+ # to the hosted server over a persistent WebSocket. Buffer commands
334
+ # (enqueue_injection, idle_state, etc.) flow through this bridge. The local
335
+ # Buffer::Server still runs for local-mode compatibility.
336
+ def start_buffer_bridge
337
+ return unless Superkick.config.server_type == :hosted
338
+
339
+ server_config = Superkick.config.server
340
+ @buffer_bridge = Hosted::Buffer::Bridge.new(
341
+ agent_id: @agent_id,
342
+ server_url: server_config[:url],
343
+ api_key: server_config[:api_key],
344
+ command_handler: method(:handle_buffer_command)
345
+ )
346
+ @buffer_bridge.start
347
+ Superkick.logger.info("pty:#{@agent_id}") { "Buffer bridge started" }
348
+ end
349
+
350
+ # In hosted mode, start the bridge that connects the local Attach::Server
351
+ # to the hosted server over a persistent WebSocket. PTY output is
352
+ # forwarded to the relay; remote user input flows back.
353
+ def start_attach_bridge
354
+ return unless Superkick.config.server_type == :hosted
355
+
356
+ server_config = Superkick.config.server
357
+ @attach_bridge = Hosted::Attach::Bridge.new(
358
+ agent_id: @agent_id,
359
+ server_url: server_config[:url],
360
+ api_key: server_config[:api_key],
361
+ attach_server: @attach_server
362
+ )
363
+ @attach_bridge.start
364
+ Superkick.logger.info("pty:#{@agent_id}") { "Attach bridge started" }
365
+ end
366
+
367
+ # Dispatch a buffer command received over the bridge.
368
+ # Returns a response hash to send back to the server.
369
+ def handle_buffer_command(message)
370
+ case message[:command]
371
+ when "enqueue_injection"
372
+ @injection_queue.enqueue(
373
+ id: message[:id],
374
+ prompt: message[:prompt],
375
+ monitor_type: message[:monitor_type],
376
+ monitor_name: message[:monitor_name],
377
+ priority: message[:priority]&.to_sym || :normal,
378
+ ttl: message[:ttl] || InjectionQueue::DEFAULT_TTL,
379
+ supersede_key: message[:supersede_key]
380
+ )
381
+ {ok: true, status: "queued"}
382
+ when "idle_state"
383
+ idle_state.merge(ok: true)
384
+ when "guards_active"
385
+ guards = @input_buffer.guards
386
+ {ok: true, active: !guards.empty?, guards: guards}
387
+ when "get"
388
+ contents = @input_buffer.contents
389
+ {ok: true, contents: contents.empty? ? nil : contents}
390
+ when "inject"
391
+ bytes = Base64.decode64(message[:data].to_s)
392
+ enqueue_inject(bytes)
393
+ {ok: true}
394
+ when "ping"
395
+ {ok: true}
396
+ else
397
+ {ok: false, error: "unknown command: #{message[:command]}"}
398
+ end
399
+ rescue => e
400
+ {ok: false, error: e.message}
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Registry — shared test isolation for class-level plugin registries.
5
+ #
6
+ # Include this module in the singleton class of any registry host that
7
+ # stores plugins in `@registry`. It provides `with_registry` for
8
+ # snapshot/restore scoping in tests, replacing ad-hoc deregister/restore
9
+ # patterns.
10
+ #
11
+ # Usage in a registry host:
12
+ #
13
+ # class MyPlugin
14
+ # @registry = {}
15
+ # class << self
16
+ # include Superkick::Registry
17
+ # # ... register, lookup, registered, etc.
18
+ # end
19
+ # end
20
+ #
21
+ # Block-scoped usage in tests (preferred):
22
+ #
23
+ # MyPlugin.with_registry do
24
+ # MyPlugin.register(StubPlugin)
25
+ # # StubPlugin visible here, cleaned up automatically
26
+ # end
27
+ #
28
+ # Optionally pre-seed the isolated registry:
29
+ #
30
+ # MyPlugin.with_registry(stub: StubPlugin) do
31
+ # assert_equal StubPlugin, MyPlugin.lookup(:stub)
32
+ # end
33
+ #
34
+ # Paired save/restore for Minitest before/after hooks:
35
+ #
36
+ # before do
37
+ # @saved = MyPlugin.snapshot_registry
38
+ # MyPlugin.register(StubPlugin)
39
+ # end
40
+ #
41
+ # after do
42
+ # MyPlugin.restore_registry(@saved)
43
+ # end
44
+ #
45
+ module Registry
46
+ # Snapshot the current registry, optionally replace it, yield, then
47
+ # restore. Thread-safe per block scope (the snapshot is local).
48
+ #
49
+ # @param entries [Hash] optional entries to seed the isolated registry with
50
+ # @yield block runs with the isolated registry
51
+ # @return the block's return value
52
+ def with_registry(**entries, &block)
53
+ saved = @registry.dup
54
+ @registry = entries.any? ? entries.dup : saved.dup
55
+ block.call
56
+ ensure
57
+ @registry = saved
58
+ end
59
+
60
+ # Take a snapshot of the current registry for later restoration.
61
+ # Use with `restore_registry` in Minitest before/after hooks.
62
+ #
63
+ # @return [Hash] frozen snapshot of the registry
64
+ def snapshot_registry
65
+ @registry.dup.freeze
66
+ end
67
+
68
+ # Restore the registry from a previous snapshot.
69
+ #
70
+ # @param snapshot [Hash] a snapshot from `snapshot_registry`
71
+ def restore_registry(snapshot)
72
+ @registry = snapshot.dup
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Repository — metadata for a single repository in the catalog.
5
+ class Repository
6
+ attr_reader :name, :path, :url, :dependencies,
7
+ :version_control, :context_documents
8
+
9
+ # Well-known context document patterns — glob relative to repo root.
10
+ CONTEXT_DOCUMENT_PATTERNS = {
11
+ readme: "README*",
12
+ claude_md: "CLAUDE.md",
13
+ agents_md: "AGENTS.md",
14
+ contributing: "CONTRIBUTING*",
15
+ conventions: "CONVENTIONS*",
16
+ cursorrules: ".cursorrules"
17
+ }.freeze
18
+
19
+ def initialize(name:, path: nil, url: nil, dependencies: [],
20
+ version_control: nil, context_documents: {})
21
+ @name = name.to_sym
22
+ @path = path && File.expand_path(path)
23
+ @url = url
24
+ @dependencies = dependencies.map(&:to_s)
25
+ @context_documents = (context_documents || {}).dup
26
+ @version_control = version_control&.to_sym
27
+ end
28
+
29
+ # Convenience accessor — returns the :readme context document content.
30
+ def readme_content
31
+ @context_documents[:readme]
32
+ end
33
+
34
+ # Convenience writer — sets the :readme context document.
35
+ # Used by OrganizationRepositorySource to pre-populate fetched content.
36
+ def readme_content=(content)
37
+ @context_documents[:readme] = content
38
+ end
39
+
40
+ # Set a named context document. Used by sources to populate documents
41
+ # after construction.
42
+ def set_context_document(name, content)
43
+ @context_documents[name.to_sym] = content
44
+ end
45
+
46
+ def to_h
47
+ h = {name: @name}
48
+ h[:path] = @path if @path
49
+ h[:url] = @url if @url
50
+ h[:version_control] = @version_control if @version_control
51
+ h[:dependencies] = @dependencies if @dependencies.any?
52
+ h[:context_documents] = @context_documents.keys if @context_documents.any?
53
+ h
54
+ end
55
+ end
56
+
57
+ # RepositorySource — abstract base class for repository sources.
58
+ #
59
+ # A repository source knows how to discover and describe repositories.
60
+ # It provides metadata (name, path, url, etc.) but does not handle
61
+ # workspace preparation — that's the job of VersionControl adapters.
62
+ #
63
+ # Subclass contract:
64
+ # self.type → unique Symbol (e.g. :local, :github_organization)
65
+ # repositories → Hash of { name_sym => Repository }
66
+ # find_by_name(n) → Repository or nil
67
+ # to_prompt_context → String for planning agent prompt
68
+ # empty? / size → catalog stats
69
+ #
70
+ # Built-in implementations:
71
+ # Local::RepositorySource — scans a local directory
72
+ # Integrations::Git::RepositorySource — single URL-based git repo
73
+ # Integrations::GitHub::OrganizationRepositorySource — lists repos from a GitHub org
74
+ # CompositeRepositorySource — unions multiple sources
75
+ class RepositorySource
76
+ # ── Registry (stores classes, keyed by type) ─────────────────────────
77
+ @registry = {}
78
+
79
+ class << self
80
+ include Superkick::Registry
81
+
82
+ def register(source_class)
83
+ key = source_class.type
84
+ raise ArgumentError, "RepositorySource :#{key} already registered" if @registry.key?(key)
85
+ @registry[key] = source_class
86
+ end
87
+
88
+ def lookup(name)
89
+ @registry[name.to_sym] or raise ArgumentError, "Unknown repository source type: #{name.inspect}"
90
+ end
91
+
92
+ def registered
93
+ @registry.dup.freeze
94
+ end
95
+
96
+ # Build a source from config.
97
+ #
98
+ # Accepts a Hash where each key is a user-chosen name and each value is either:
99
+ # - a typed source config (has :type key) → built as its own typed source
100
+ # - an untyped entry with :path → wrapped as a Local::RepositorySource (depth 0)
101
+ #
102
+ # Entries without :type and without :path are silently skipped.
103
+ #
104
+ # When the result is a single source, returns it directly.
105
+ # When there are multiple, composites them.
106
+ def build(config)
107
+ config = {} if config.nil?
108
+ return Local::RepositorySource.new({}) if config.empty?
109
+
110
+ sources = []
111
+
112
+ config.each do |name, entry_config|
113
+ next unless entry_config.is_a?(Hash)
114
+
115
+ if entry_config.key?(:type)
116
+ klass = lookup(entry_config[:type].to_sym)
117
+ sources << klass.new(entry_config.merge(name:))
118
+ elsif entry_config[:path]
119
+ sources << Local::RepositorySource.new(path: entry_config[:path], depth: 0, name:)
120
+ end
121
+ end
122
+
123
+ case sources.size
124
+ when 0 then Local::RepositorySource.new({})
125
+ when 1 then sources.first
126
+ else CompositeRepositorySource.new(sources)
127
+ end
128
+ end
129
+ end
130
+
131
+ def self.type
132
+ raise NotImplementedError, "#{self}.type not implemented"
133
+ end
134
+
135
+ # Setup wizard metadata. Override in subclasses that should appear in `superkick setup`.
136
+ def self.setup_label = nil
137
+ def self.setup_config = nil
138
+
139
+ def repositories
140
+ raise NotImplementedError, "#{self.class}#repositories not implemented"
141
+ end
142
+
143
+ def find_by_name(name)
144
+ repositories[name.to_sym]
145
+ end
146
+
147
+ def to_prompt_context
148
+ return "No repositories configured." if repositories.empty?
149
+
150
+ repositories.map do |name, repository|
151
+ lines = ["### #{name}"]
152
+ lines << "Dependencies: #{repository.dependencies.join(", ")}" if repository.dependencies.any?
153
+ repository.context_documents.each do |doc_name, content|
154
+ excerpt = content.lines.first(30).join
155
+ lines << ""
156
+ lines << "#{doc_name.to_s.tr("_", " ").capitalize} (excerpt):"
157
+ lines << excerpt
158
+ end
159
+ lines.join("\n")
160
+ end.join("\n\n")
161
+ end
162
+
163
+ def empty?
164
+ repositories.empty?
165
+ end
166
+
167
+ def size
168
+ repositories.size
169
+ end
170
+ end
171
+
172
+ # CompositeRepositorySource — unions multiple sources into one.
173
+ #
174
+ # Built automatically by RepositorySource.build when the config contains
175
+ # multiple sources. Earlier entries win on name collisions
176
+ # (first-found precedence).
177
+ class CompositeRepositorySource < RepositorySource
178
+ def self.type = :composite
179
+
180
+ def initialize(children = [])
181
+ @children = children
182
+ @repositories = children
183
+ .each_with_object({}) do |child, merged|
184
+ child.repositories.each do |name, repository|
185
+ merged[name] ||= repository
186
+ end
187
+ end
188
+ .freeze
189
+ end
190
+
191
+ attr_reader :repositories, :children
192
+ end
193
+
194
+ RepositorySource.register(CompositeRepositorySource)
195
+ end