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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Attach
5
+ # Binary wire protocol for the attach system. Shared by Attach::Server and
6
+ # Attach::Client. Uses length-prefixed frames — 1 byte type + 4 byte big-endian
7
+ # length + payload. JSON is only used for infrequent control messages (meta,
8
+ # resize, hello); output and input frames carry raw bytes with zero overhead.
9
+ module Protocol
10
+ OUTPUT = 0x01
11
+ INPUT = 0x02
12
+ META = 0x03
13
+ RESIZE = 0x04
14
+ HISTORY = 0x05
15
+ ERROR = 0x06
16
+ HELLO = 0x07
17
+ COMMAND = 0x08
18
+ NOTIFY = 0x09
19
+
20
+ # Write a single frame to an IO.
21
+ # @param io [IO] writable socket
22
+ # @param type [Integer] message type constant
23
+ # @param data [String] payload bytes
24
+ def self.write_frame(io, type, data)
25
+ data = data.b if data.encoding != Encoding::BINARY
26
+ header = [type, data.bytesize].pack("CN")
27
+ io.write(header)
28
+ io.write(data)
29
+ io.flush
30
+ end
31
+
32
+ # Read a single frame from an IO.
33
+ # @return [Array(Integer, String)] [type, payload] or nil on EOF
34
+ def self.read_frame(io)
35
+ header = io.read(5)
36
+ return nil unless header && header.bytesize == 5
37
+
38
+ type, len = header.unpack("CN")
39
+ payload = if len > 0
40
+ io.read(len)
41
+ else
42
+ "".b
43
+ end
44
+ return nil unless payload && payload.bytesize == len
45
+
46
+ [type, payload]
47
+ end
48
+
49
+ # Build a frame as a binary string without writing to an IO.
50
+ # Used by the WebSocket bridges and transports to construct frames
51
+ # for sending over WebSocket binary messages.
52
+ # @param type [Integer] message type constant
53
+ # @param data [String] payload bytes
54
+ # @return [String] binary frame bytes
55
+ def self.build_frame(type, data)
56
+ data = data.b if data.encoding != Encoding::BINARY
57
+ [type, data.bytesize].pack("CN") + data
58
+ end
59
+
60
+ # Convenience: write a JSON-encoded frame.
61
+ def self.write_json_frame(io, type, hash)
62
+ write_frame(io, type, JSON.generate(hash))
63
+ end
64
+
65
+ # Convenience: decode a JSON payload.
66
+ def self.decode_json(payload)
67
+ JSON.parse(payload, symbolize_names: true)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+
6
+ module Superkick
7
+ module Attach
8
+ # Per-agent attach endpoint. Runs inside the `superkick agent` process
9
+ # alongside Buffer::Server. Manages connected attach clients and broadcasts
10
+ # PTY output to all of them.
11
+ #
12
+ # Supports multiple concurrent read-only clients and at most one read-write
13
+ # client at a time. Read-write clients can forward keystrokes and terminal
14
+ # resize events to the PTY.
15
+ #
16
+ # Mode switching:
17
+ # - Force-takeover: HELLO with `force: true` demotes existing RW holder
18
+ # - In-session: COMMAND frames with `promote_rw`, `force_promote_rw`,
19
+ # or `demote_ro` actions to switch modes without reconnecting
20
+ # - Auto-promote: claiming an agent auto-promotes to RW when slot is vacant
21
+ # - Idle timeout: RW clients auto-demoted after `attach_rw_idle_timeout`
22
+ # seconds of no INPUT frames
23
+ class Server
24
+ attr_reader :socket_path, :history
25
+
26
+ def initialize(agent_id:, pty_proxy:, control_client: nil, config: Superkick.config)
27
+ @agent_id = agent_id
28
+ @pty_proxy = pty_proxy
29
+ @control_client = control_client
30
+ @history = HistoryBuffer.new(capacity: config.attach_history_size)
31
+ @socket_path = config.attach_socket_path(agent_id)
32
+ @rw_idle_timeout = config.attach_rw_idle_timeout
33
+ @clients = [] # Array of { socket:, mode: }
34
+ @clients_mutex = Mutex.new
35
+ @server = nil
36
+ @accept_thread = nil
37
+ @idle_check_thread = nil
38
+ @rw_client = nil # at most one read-write client
39
+ @rw_last_input_at = nil # monotonic timestamp of last INPUT frame from RW client
40
+ @broadcast_callbacks = []
41
+ end
42
+
43
+ def start
44
+ FileUtils.mkdir_p(File.dirname(@socket_path))
45
+ FileUtils.rm_f(@socket_path)
46
+ @server = UNIXServer.new(@socket_path)
47
+ @accept_thread = Thread.new { accept_loop }
48
+ start_idle_check if @rw_idle_timeout && @rw_idle_timeout > 0
49
+ self
50
+ end
51
+
52
+ def stop
53
+ begin
54
+ @server&.close
55
+ rescue IOError
56
+ nil
57
+ end
58
+ begin
59
+ @accept_thread&.kill
60
+ rescue ThreadError
61
+ nil
62
+ end
63
+ begin
64
+ @idle_check_thread&.kill
65
+ rescue ThreadError
66
+ nil
67
+ end
68
+ @clients_mutex.synchronize do
69
+ @clients.each do |c|
70
+ c[:socket].close
71
+ rescue IOError
72
+ nil
73
+ end
74
+ @clients.clear
75
+ end
76
+ FileUtils.rm_f(@socket_path)
77
+ end
78
+
79
+ # Register a callback that receives every data chunk passed to broadcast().
80
+ # Used by the Hosted::Attach::Bridge to forward output to the relay.
81
+ def on_broadcast(&block)
82
+ @broadcast_callbacks << block
83
+ end
84
+
85
+ # Called by PtyProxy#proxy_output on every chunk of PTY output.
86
+ # Writes to history buffer and broadcasts to all connected clients.
87
+ def broadcast(data)
88
+ @history.write(data)
89
+
90
+ @clients_mutex.synchronize do
91
+ @clients.reject! do |client|
92
+ Protocol.write_frame(client[:socket], Protocol::OUTPUT, data)
93
+ false
94
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
95
+ Superkick.logger.debug("attach:#{@agent_id}") { "Client disconnected" }
96
+ @rw_client = nil if @rw_client == client[:socket]
97
+ true # remove from list
98
+ end
99
+ end
100
+
101
+ @broadcast_callbacks.each { it.call(data) }
102
+ end
103
+
104
+ # Accept a raw Protocol frame from the hosted bridge (remote input).
105
+ # The relay handles RW exclusivity for remote users — it only forwards
106
+ # INPUT and RESIZE frames from the RW user.
107
+ def handle_remote_input(data)
108
+ data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
109
+ return if data.bytesize < 5
110
+
111
+ type = data.getbyte(0)
112
+ payload_len = data[1, 4].unpack1("N")
113
+ payload = data[5, payload_len]
114
+ return unless payload
115
+
116
+ case type
117
+ when Protocol::INPUT
118
+ @pty_proxy.enqueue_inject(payload)
119
+ when Protocol::RESIZE
120
+ resize_data = Protocol.decode_json(payload)
121
+ @pty_proxy.resize(resize_data[:rows], resize_data[:cols])
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def accept_loop
128
+ loop do
129
+ raw = @server.accept
130
+ Thread.new(raw) { handle_client(it) }
131
+ rescue IOError, Errno::EBADF
132
+ break
133
+ rescue => e
134
+ Superkick.logger.error("attach:#{@agent_id}") {
135
+ "Accept error: #{e.message}"
136
+ }
137
+ end
138
+ end
139
+
140
+ def handle_client(socket)
141
+ # 1. Read hello frame
142
+ frame = Protocol.read_frame(socket)
143
+ unless frame && frame[0] == Protocol::HELLO
144
+ begin
145
+ socket.close
146
+ rescue IOError
147
+ nil
148
+ end
149
+ return
150
+ end
151
+
152
+ hello = Protocol.decode_json(frame[1])
153
+ mode = (hello[:mode] == "rw") ? :rw : :ro
154
+ force = hello[:force] == true
155
+
156
+ # 2. Check read-write exclusivity
157
+ if mode == :rw
158
+ @clients_mutex.synchronize do
159
+ if @rw_client
160
+ if force
161
+ demote_rw_client_locked("Read-write taken over by another client", event_type: :attach_force_takeover)
162
+ else
163
+ Protocol.write_json_frame(socket, Protocol::ERROR,
164
+ {message: "Another read-write client is already connected"})
165
+ begin
166
+ socket.close
167
+ rescue IOError
168
+ nil
169
+ end
170
+ return
171
+ end
172
+ end
173
+ @rw_client = socket
174
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+ end
176
+ end
177
+
178
+ # 3. Send metadata
179
+ meta = build_meta
180
+ Protocol.write_json_frame(socket, Protocol::META, meta)
181
+
182
+ # 4. Replay history
183
+ history_data = @history.snapshot
184
+ unless history_data.empty?
185
+ Protocol.write_frame(socket, Protocol::HISTORY, history_data)
186
+ end
187
+
188
+ # 5. Register client for broadcast
189
+ client_entry = {socket:, mode:}
190
+ @clients_mutex.synchronize { @clients << client_entry }
191
+
192
+ # 6. Unified frame processing loop — all clients (RW and RO) use the
193
+ # same loop. INPUT and RESIZE are only forwarded for the active RW
194
+ # client; COMMAND frames are always processed.
195
+ read_client_frames(socket)
196
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
197
+ # Client disconnected
198
+ ensure
199
+ @clients_mutex.synchronize do
200
+ @clients.reject! { |c| c[:socket] == socket }
201
+ @rw_client = nil if @rw_client == socket
202
+ end
203
+ begin
204
+ socket.close
205
+ rescue IOError
206
+ nil
207
+ end
208
+ end
209
+
210
+ def read_client_frames(socket)
211
+ loop do
212
+ frame = Protocol.read_frame(socket)
213
+ break unless frame
214
+
215
+ type, payload = frame
216
+ case type
217
+ when Protocol::INPUT
218
+ if @rw_client == socket
219
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ @pty_proxy.enqueue_inject(payload)
221
+ end
222
+ when Protocol::RESIZE
223
+ if @rw_client == socket
224
+ data = Protocol.decode_json(payload)
225
+ @pty_proxy.resize(data[:rows], data[:cols])
226
+ end
227
+ when Protocol::COMMAND
228
+ cmd = Protocol.decode_json(payload)
229
+ handle_attach_command(cmd, socket)
230
+ end
231
+ end
232
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
233
+ # Client disconnected
234
+ end
235
+
236
+ def handle_attach_command(cmd, socket)
237
+ case cmd[:action]
238
+ when "claim"
239
+ control = Control.client_from
240
+ result = control.request("claim_agent", agent_id: @agent_id)
241
+ Superkick.logger.info("attach:#{@agent_id}") { "Claim via attach: #{result.success? ? "ok" : result.error_message}" }
242
+ # Auto-promote to RW when claiming and slot is vacant
243
+ if result.success?
244
+ @clients_mutex.synchronize do
245
+ promote_client_locked(socket) unless @rw_client
246
+ end
247
+ end
248
+ when "unclaim"
249
+ control = Control.client_from
250
+ result = control.request("unclaim_agent", agent_id: @agent_id)
251
+ Superkick.logger.info("attach:#{@agent_id}") { "Unclaim via attach: #{result.success? ? "ok" : result.error_message}" }
252
+ when "promote_rw"
253
+ @clients_mutex.synchronize do
254
+ if @rw_client == socket
255
+ notify_client(socket, "Already in read-write mode")
256
+ elsif @rw_client
257
+ notify_client(socket, "Read-write slot is occupied — use forced promotion to take over")
258
+ else
259
+ promote_client_locked(socket)
260
+ end
261
+ end
262
+ when "force_promote_rw"
263
+ @clients_mutex.synchronize do
264
+ if @rw_client == socket
265
+ notify_client(socket, "Already in read-write mode")
266
+ else
267
+ demote_rw_client_locked("Read-write taken over by another client", event_type: :attach_force_takeover) if @rw_client
268
+ promote_client_locked(socket)
269
+ end
270
+ end
271
+ when "demote_ro"
272
+ @clients_mutex.synchronize do
273
+ if @rw_client == socket
274
+ @rw_client = nil
275
+ @rw_last_input_at = nil
276
+ entry = @clients.find { it[:socket] == socket }
277
+ entry[:mode] = :ro if entry
278
+ Superkick.logger.info("attach:#{@agent_id}") { "Client voluntarily demoted to read-only" }
279
+ notify_client(socket, "Switched to read-only mode")
280
+ else
281
+ notify_client(socket, "Already in read-only mode")
282
+ end
283
+ end
284
+ end
285
+ rescue Control::Client::ServerUnavailable
286
+ Superkick.logger.warn("attach:#{@agent_id}") { "Cannot #{cmd[:action]}: server unavailable" }
287
+ end
288
+
289
+ # Promote a client to RW. MUST be called while holding @clients_mutex.
290
+ def promote_client_locked(socket)
291
+ @rw_client = socket
292
+ @rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
293
+
294
+ entry = @clients.find { it[:socket] == socket }
295
+ entry[:mode] = :rw if entry
296
+
297
+ Superkick.logger.info("attach:#{@agent_id}") { "Client promoted to read-write" }
298
+ notify_client(socket, "Promoted to read-write mode")
299
+ dispatch_attach_event(:attach_promoted, "Client promoted to read-write mode")
300
+ end
301
+
302
+ # Demote the current RW client to RO. Sends a NOTIFY frame to inform them.
303
+ # MUST be called while holding @clients_mutex.
304
+ def demote_rw_client_locked(reason, event_type: :attach_demoted)
305
+ old_socket = @rw_client
306
+ return unless old_socket
307
+
308
+ @rw_client = nil
309
+ @rw_last_input_at = nil
310
+
311
+ entry = @clients.find { it[:socket] == old_socket }
312
+ entry[:mode] = :ro if entry
313
+
314
+ Superkick.logger.info("attach:#{@agent_id}") { "RW client demoted: #{reason}" }
315
+ notify_client(old_socket, reason)
316
+ dispatch_attach_event(event_type, reason)
317
+ end
318
+
319
+ # Send a NOTIFY frame to a client. Swallows IO errors.
320
+ def notify_client(socket, message)
321
+ Protocol.write_json_frame(socket, Protocol::NOTIFY, {message:})
322
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
323
+ nil
324
+ end
325
+
326
+ # Fire a notification event to the server so it can dispatch to notifiers
327
+ # (Slack, command, terminal bell, etc.). Best-effort — swallows errors.
328
+ def dispatch_attach_event(event_type, reason)
329
+ return unless @control_client
330
+
331
+ @control_client.request("attach_event",
332
+ agent_id: @agent_id,
333
+ event_type: event_type.to_s,
334
+ reason:)
335
+ rescue Control::Client::ServerUnavailable
336
+ nil
337
+ end
338
+
339
+ # Background thread that periodically checks if the RW client has been
340
+ # idle (no INPUT frames) past the configured timeout.
341
+ def start_idle_check
342
+ @idle_check_thread = Thread.new do
343
+ check_interval = [@rw_idle_timeout / 2.0, 30].min
344
+ loop do
345
+ sleep check_interval
346
+ @clients_mutex.synchronize do
347
+ next unless @rw_client && @rw_last_input_at
348
+
349
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @rw_last_input_at
350
+ if elapsed >= @rw_idle_timeout
351
+ demote_rw_client_locked(
352
+ "Demoted to read-only after #{@rw_idle_timeout.to_i}s of inactivity",
353
+ event_type: :attach_idle_timeout
354
+ )
355
+ end
356
+ end
357
+ rescue => e
358
+ Superkick.logger.error("attach:#{@agent_id}") { "Idle check error: #{e.message}" }
359
+ end
360
+ end
361
+ end
362
+
363
+ def build_meta
364
+ {
365
+ agent_id: @agent_id,
366
+ history_bytes: @history.size
367
+ }
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Centralized budget evaluation. Checks per-agent, per-spawner,
5
+ # and global budget limits on each cost report.
6
+ #
7
+ # Returns an array of violations, each a Hash:
8
+ # { level: :agent/:spawner/:global, budget:, spent:, action: :warning/:exceeded }
9
+ class BudgetChecker
10
+ def initialize(store:, config: Superkick.config)
11
+ @store = store
12
+ @config = config
13
+ end
14
+
15
+ # Check all budget levels for an agent.
16
+ # @return [Array<Hash>] violations (empty means all budgets ok)
17
+ def check(agent)
18
+ violations = []
19
+ violations.concat(check_agent_budget(agent))
20
+ violations.concat(check_spawner_budget(agent))
21
+ violations.concat(check_global_budget)
22
+ violations
23
+ end
24
+
25
+ private
26
+
27
+ def check_agent_budget(agent)
28
+ per_agent = per_agent_budget_config(agent)
29
+ return [] unless per_agent
30
+
31
+ max = per_agent[:max_cost_usd]
32
+ return [] unless max
33
+
34
+ spent = agent.cost.to_h[:total_cost_usd]
35
+ warn_pct = per_agent[:warning_threshold_percentage] || 80
36
+
37
+ if spent >= max
38
+ [{level: :agent, budget: max, spent:, action: :exceeded}]
39
+ elsif spent >= (max * warn_pct / 100.0)
40
+ [{level: :agent, budget: max, spent:, action: :warning}]
41
+ else
42
+ []
43
+ end
44
+ end
45
+
46
+ def check_spawner_budget(agent)
47
+ return [] unless agent.spawn_info
48
+
49
+ spawner_name = agent.spawn_info[:spawner_name]&.to_sym
50
+ config = @config.spawners[spawner_name]
51
+ return [] unless config
52
+
53
+ max = config.dig(:budget, :max_cost_usd)
54
+ return [] unless max
55
+
56
+ window = (config.dig(:budget, :window_hours) || 24) * 3600
57
+ cutoff = Time.now - window
58
+
59
+ total = 0.0
60
+ @store.each do |a|
61
+ next unless a.spawn_info&.dig(:spawner_name)&.to_sym == spawner_name
62
+ spawned_at = a.spawn_info[:spawned_at]
63
+ next unless spawned_at
64
+ begin
65
+ next if Time.iso8601(spawned_at) < cutoff
66
+ rescue ArgumentError
67
+ next
68
+ end
69
+ total += a.cost.to_h[:total_cost_usd]
70
+ end
71
+
72
+ warn_pct = config.dig(:budget, :warning_threshold_percentage) || 80
73
+
74
+ if total >= max
75
+ [{level: :spawner, budget: max, spent: total, action: :exceeded}]
76
+ elsif total >= (max * warn_pct / 100.0)
77
+ [{level: :spawner, budget: max, spent: total, action: :warning}]
78
+ else
79
+ []
80
+ end
81
+ end
82
+
83
+ def check_global_budget
84
+ max = @config.budget[:max_cost_usd]
85
+ return [] unless max
86
+
87
+ window = (@config.budget[:window_hours] || 24) * 3600
88
+ cutoff = Time.now - window
89
+
90
+ total = 0.0
91
+ @store.each do |a|
92
+ next unless a.spawn_info
93
+ spawned_at = a.spawn_info[:spawned_at]
94
+ next unless spawned_at
95
+ begin
96
+ next if Time.iso8601(spawned_at) < cutoff
97
+ rescue ArgumentError
98
+ next
99
+ end
100
+ total += a.cost.to_h[:total_cost_usd]
101
+ end
102
+
103
+ warn_pct = @config.budget[:warning_threshold_percentage] || 80
104
+
105
+ if total >= max
106
+ [{level: :global, budget: max, spent: total, action: :exceeded}]
107
+ elsif total >= (max * warn_pct / 100.0)
108
+ [{level: :global, budget: max, spent: total, action: :warning}]
109
+ else
110
+ []
111
+ end
112
+ end
113
+
114
+ def per_agent_budget_config(agent)
115
+ return nil unless agent.spawn_info
116
+ spawner_name = agent.spawn_info[:spawner_name]&.to_sym
117
+ @config.spawners.dig(spawner_name, :budget, :per_agent)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Superkick
6
+ module Buffer
7
+ @clients = {}
8
+ extend ClientRegistry
9
+
10
+ # Buffer client for communicating with agents' buffer servers.
11
+ #
12
+ # The default implementation communicates over a Unix socket. In hosted
13
+ # mode, a subclass (Hosted::Buffer::Client) routes commands through
14
+ # WebSocket relays.
15
+ #
16
+ # All server-side components (Injector, SpawnInjector, CostPoller,
17
+ # Supervisor) use Buffer.client_from to construct the right implementation
18
+ # for the configured server type.
19
+ class Client
20
+ # Shared exception that callers rescue regardless of server type.
21
+ class AgentUnreachable < StandardError; end
22
+
23
+ def self.from(store:, **_kwargs)
24
+ new(store:)
25
+ end
26
+
27
+ def initialize(store:)
28
+ @store = store
29
+ end
30
+
31
+ # Send a command to the agent's buffer server and return the parsed response.
32
+ # @param agent_id [String]
33
+ # @param command [String] buffer command name
34
+ # @param params [Hash] extra key/value params merged into the request
35
+ # @return [Hash] parsed response from the buffer server
36
+ def request(agent_id, command, **params)
37
+ socket_path = resolve_socket_path(agent_id)
38
+ connection = Superkick::Connection.open(socket_path)
39
+ begin
40
+ connection.send_message({command:}.merge(params))
41
+ response = connection.receive_message
42
+ raise AgentUnreachable, "Buffer server closed connection" unless response
43
+ response
44
+ ensure
45
+ connection.close
46
+ end
47
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::ECONNRESET
48
+ raise AgentUnreachable, "Buffer socket not found for agent #{agent_id}"
49
+ end
50
+
51
+ # Fire-and-forget: send a command, log and swallow AgentUnreachable.
52
+ def send_command(agent_id, command, **params)
53
+ request(agent_id, command, **params)
54
+ rescue AgentUnreachable => e
55
+ Superkick.logger.debug(log_tag) { "Send failed for #{agent_id}: #{e.message}" }
56
+ nil
57
+ end
58
+
59
+ # Check if the agent's buffer socket is reachable.
60
+ def reachable?(agent_id)
61
+ agent = @store.get(agent_id)
62
+ return false unless agent&.buffer_socket_path
63
+
64
+ socket_path = agent.buffer_socket_path
65
+ File.exist?(socket_path)
66
+ end
67
+
68
+ # Probe the agent's buffer endpoint to verify it's alive.
69
+ def probe(agent_id)
70
+ response = request(agent_id, "ping")
71
+ response&.fetch(:ok, false) == true
72
+ rescue AgentUnreachable, SystemCallError, IOError, JSON::ParserError
73
+ false
74
+ end
75
+
76
+ private
77
+
78
+ def log_tag
79
+ "buffer:client"
80
+ end
81
+
82
+ def resolve_socket_path(agent_id)
83
+ agent = @store.get(agent_id)
84
+ socket_path = agent&.buffer_socket_path
85
+ raise AgentUnreachable, "No buffer socket for agent #{agent_id}" unless socket_path && File.exist?(socket_path)
86
+
87
+ socket_path
88
+ end
89
+ end
90
+ end
91
+ end