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,1271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+ require "base64"
6
+ require "securerandom"
7
+ require_relative "reply"
8
+
9
+ module Superkick
10
+ module Control
11
+ # Unix socket server — the control plane's main endpoint.
12
+ # One thread per connection (connections are short-lived request/response).
13
+ #
14
+ # Commands are dispatched dynamically to cmd_<command> private methods.
15
+ class Server
16
+ def initialize(store:, injector:, supervisor:, notification_dispatcher:, buffer_client: nil, config: Superkick.config,
17
+ agent_spawner: nil, approval_store: nil, budget_checker: nil, team_log_store: nil,
18
+ team_artifact_store: nil, server: nil, attach_relay_store: nil)
19
+ @store = store
20
+ @injector = injector
21
+ @supervisor = supervisor
22
+ @config = config
23
+ @buffer_client = buffer_client || Buffer.client_from(store:, config:)
24
+ @agent_spawner = agent_spawner
25
+ @approval_store = approval_store
26
+ @budget_checker = budget_checker
27
+ @team_log_store = team_log_store
28
+ @team_artifact_store = team_artifact_store
29
+ @server = server
30
+ @notification_dispatcher = notification_dispatcher
31
+ @attach_relay_store = attach_relay_store
32
+ @accept_thread = nil
33
+ end
34
+
35
+ def start
36
+ unless @server
37
+ FileUtils.rm_f(@config.socket_path)
38
+ FileUtils.mkdir_p(File.dirname(@config.socket_path))
39
+ @server = UNIXServer.new(@config.socket_path)
40
+ end
41
+ @accept_thread = Thread.new { accept_loop }
42
+ self
43
+ end
44
+
45
+ def stop
46
+ begin
47
+ @server&.close
48
+ rescue IOError, Errno::EBADF
49
+ nil
50
+ end
51
+ begin
52
+ @accept_thread&.kill
53
+ rescue ThreadError
54
+ nil
55
+ end
56
+ FileUtils.rm_f(@config.socket_path)
57
+ end
58
+
59
+ private
60
+
61
+ def accept_loop
62
+ loop do
63
+ connection = @server.accept
64
+ Thread.new(connection) { handle(it) }
65
+ rescue IOError, Errno::EBADF
66
+ break
67
+ rescue => e
68
+ Superkick.logger.error("control_server") { "Control::Server accept error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
69
+ end
70
+ end
71
+
72
+ def handle(raw_connection)
73
+ connection = Superkick::Connection.new(raw_connection)
74
+ request = connection.receive_message
75
+ unless request
76
+ connection.close
77
+ return
78
+ end
79
+
80
+ connection.reply(dispatch(request) || {})
81
+ rescue => e
82
+ Superkick.logger.error("control_server") { "Control::Server dispatch error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
83
+ begin
84
+ connection&.error(e.message)
85
+ rescue IOError, Errno::EPIPE, Errno::ENOTCONN
86
+ nil
87
+ end
88
+ ensure
89
+ connection&.close
90
+ end
91
+
92
+ def dispatch(request)
93
+ method_name = "cmd_#{request[:command].tr("-", "_")}"
94
+ unless respond_to?(method_name, true)
95
+ raise "Unknown command: #{request[:command]}"
96
+ end
97
+ send(method_name, request)
98
+ end
99
+
100
+ def cmd_ping(_req)
101
+ {version: Superkick::VERSION}
102
+ end
103
+
104
+ def cmd_register(req)
105
+ agent_id = req[:agent_id]
106
+ yaml_monitors = @config.monitors || {}
107
+ @store.add(agent_id, monitors: yaml_monitors)
108
+
109
+ # Store the agent's working directory for monitor config resolution.
110
+ if req[:working_dir]
111
+ agent = @store.get(agent_id)
112
+ agent&.set_working_dir(req[:working_dir])
113
+ end
114
+
115
+ # Human team member joining via --team flag
116
+ if req[:team_id]
117
+ agent = @store.get(agent_id)
118
+ if agent
119
+ agent.set_team(team_id: req[:team_id], team_role: :member)
120
+ agent.set_role(req[:role]) if req[:role]
121
+
122
+ if req[:team_log] != false
123
+ agent.set_monitor_config(:team_log, {type: :team_log, team_id: req[:team_id]})
124
+ @supervisor.enqueue(:add_monitor, agent_id:, monitor_name: :team_log)
125
+ end
126
+
127
+ dispatch_notification(
128
+ event: {event_type: :agent_spawned, monitor_type: :system, monitor_name: :system,
129
+ team_id: req[:team_id]},
130
+ agent_id:,
131
+ message: "#{agent_id} joined the team"
132
+ )
133
+ end
134
+ end
135
+
136
+ # Build environment request from all registered probes.
137
+ environment_request = collect_environment_actions
138
+ {agent_id:, environment_request:}
139
+ end
140
+
141
+ def cmd_register_environment(req)
142
+ agent_id = req[:agent_id]
143
+ environment = req[:environment] || {}
144
+
145
+ agent = @store.get(agent_id)
146
+ return {agent_id:} unless agent
147
+
148
+ agent.set_environment(environment)
149
+
150
+ # Run server-side probes with the environment snapshot.
151
+ probe_monitors = Monitor.detect_all(environment:)
152
+ yaml_monitors = agent.monitors || {}
153
+
154
+ # YAML is the base; probe-detected configs overlay per-name.
155
+ merged = yaml_monitors.merge(probe_monitors) { |_name, yaml_config, probe_config|
156
+ yaml_config.merge(probe_config)
157
+ }
158
+
159
+ # Replace monitors with the merged set.
160
+ merged.each { |name, config| agent.set_monitor_config(name, config) }
161
+
162
+ @supervisor.enqueue(:register, agent_id:)
163
+ {agent_id:}
164
+ end
165
+
166
+ def cmd_unregister(req)
167
+ agent_id = req[:agent_id]
168
+
169
+ # Dispatch lifecycle notification when a team member leaves
170
+ agent = @store.get(agent_id)
171
+ if agent&.team_id && agent&.team_role == :member
172
+ dispatch_notification(
173
+ event: {event_type: :agent_terminated, monitor_type: :system, monitor_name: :system,
174
+ team_id: agent.team_id},
175
+ agent_id:,
176
+ message: "#{agent_id} left the team"
177
+ )
178
+ end
179
+
180
+ @supervisor.enqueue(:unregister, agent_id:)
181
+ @store.remove(agent_id)
182
+ @agent_spawner&.agent_ended(agent_id)
183
+ nil
184
+ end
185
+
186
+ def cmd_list_agents(req)
187
+ source = if req[:team_id]
188
+ @store.team(req[:team_id])
189
+ else
190
+ @store.to_a
191
+ end
192
+ source = source.select { it.spawn_info } if req[:spawned_only]
193
+
194
+ agents = source.map { serialize_agent(it) }
195
+ {agents:}
196
+ end
197
+
198
+ def cmd_list_teams(_req)
199
+ teams = {}
200
+ @store.each do |agent|
201
+ next unless agent.team_id
202
+ entry = teams[agent.team_id] ||= {team_id: agent.team_id, agent_count: 0}
203
+ entry[:agent_count] += 1
204
+ if agent.team_role == :lead
205
+ entry[:lead_agent_id] = agent.id
206
+ end
207
+ end
208
+ {teams: teams.values}
209
+ end
210
+
211
+ def cmd_register_buffer(req)
212
+ agent_id = req[:agent_id]
213
+ agent = @store.get(agent_id)
214
+ agent&.attach_path(:buffer_socket_path, @config.buffer_socket_path(agent_id))
215
+ agent&.attach_path(:output_log_path, req[:output_log_path]) if req[:output_log_path]
216
+ agent&.attach_path(:recording_path, req[:recording_path]) if req[:recording_path]
217
+ Superkick.logger.info("control_server") { "Buffer socket registered for #{agent_id}" }
218
+ nil
219
+ end
220
+
221
+ def cmd_unregister_buffer(req)
222
+ @store.get(req[:agent_id])&.detach_path(:buffer_socket_path)
223
+ nil
224
+ end
225
+
226
+ def cmd_register_attach(req)
227
+ agent_id = req[:agent_id]
228
+ @store.get(agent_id)&.attach_path(:attach_socket_path, @config.attach_socket_path(agent_id))
229
+ Superkick.logger.info("control_server") { "Attach socket registered for #{agent_id}" }
230
+ nil
231
+ end
232
+
233
+ def cmd_unregister_attach(req)
234
+ @store.get(req[:agent_id])&.detach_path(:attach_socket_path)
235
+ nil
236
+ end
237
+
238
+ def cmd_has_buffer(req)
239
+ {has_buffer: !@store.get(req[:agent_id])&.buffer_socket_path.nil?}
240
+ end
241
+
242
+ def cmd_get_output_log_path(req)
243
+ agent_id = req[:agent_id]
244
+ agent = @store.get(agent_id)
245
+ raise "Agent not found: #{agent_id}" unless agent
246
+ {path: agent.output_log_path}
247
+ end
248
+
249
+ def cmd_get_input_buffer(req)
250
+ proxy_buffer(req[:agent_id], "get")
251
+ end
252
+
253
+ def cmd_clear_input_buffer(req)
254
+ proxy_buffer(req[:agent_id], "clear")
255
+ end
256
+
257
+ def cmd_guards_active(req)
258
+ proxy_buffer(req[:agent_id], "guards_active")
259
+ end
260
+
261
+ def cmd_inject_input(req)
262
+ proxy_buffer(req[:agent_id], "inject", data: req[:data])
263
+ end
264
+
265
+ def cmd_injection_result(req)
266
+ agent_id = req[:agent_id]
267
+ id = req[:id]
268
+ status = req[:status]&.to_sym
269
+
270
+ case status
271
+ when :injected
272
+ agent = @store.get(agent_id)
273
+ agent&.set_last_notified
274
+
275
+ dispatch_notification(
276
+ event: {event_type: :injection_completed, monitor_type: :system, monitor_name: :injection_queue},
277
+ agent_id:,
278
+ message: "Injection #{id} delivered to #{agent_id}"
279
+ )
280
+ when :expired, :superseded, :dropped
281
+ Superkick.logger.debug("injection") { "Injection #{id} #{status} for #{agent_id}" }
282
+ end
283
+
284
+ nil
285
+ end
286
+
287
+ def cmd_attach_event(req)
288
+ agent_id = req[:agent_id]
289
+ event_type = req[:event_type]&.to_sym
290
+ reason = req[:reason]
291
+
292
+ valid_events = %i[attach_promoted attach_demoted attach_idle_timeout attach_force_takeover]
293
+ return unless valid_events.include?(event_type)
294
+
295
+ dispatch_notification(
296
+ event: {event_type:, monitor_type: :attach, monitor_name: :attach},
297
+ agent_id:,
298
+ message: reason || "Attach event #{event_type} for #{agent_id}"
299
+ )
300
+
301
+ nil
302
+ end
303
+
304
+ def cmd_add_monitor(req)
305
+ agent_id = req[:agent_id]
306
+ name = req[:monitor_name]
307
+ config = req[:config] || {}
308
+ monitor_type = config[:type] || name
309
+ raise_if_privileged!(name, monitor_type, action: "add")
310
+ @store.get(agent_id)&.set_monitor_config(name, config)
311
+ @supervisor.enqueue(:add_monitor, agent_id:, monitor_name: name)
312
+ nil
313
+ end
314
+
315
+ def cmd_remove_monitor(req)
316
+ agent_id = req[:agent_id]
317
+ name = req[:monitor_name]
318
+ agent = @store.get(agent_id)
319
+ existing_config = agent&.monitor_config(name) || {}
320
+ monitor_type = existing_config[:type] || name
321
+ raise_if_privileged!(name, monitor_type, action: "remove")
322
+ @supervisor.enqueue(:remove_monitor, agent_id:, monitor_name: name)
323
+ agent&.remove_monitor(name)
324
+ nil
325
+ end
326
+
327
+ def cmd_list_monitors(req)
328
+ agent_id = req[:agent_id]
329
+ agent = @store.get(agent_id)
330
+ raise "Agent not found: #{agent_id}" unless agent
331
+
332
+ monitors = agent.monitors.reject { |name, config| monitor_hidden?(name, config) }
333
+ .map { |name, config| {name:, type: config[:type] || name} }
334
+
335
+ {monitors:}
336
+ end
337
+
338
+ def cmd_restart_monitors(req)
339
+ @supervisor.enqueue(:restart_monitors, agent_id: req[:agent_id])
340
+ nil
341
+ end
342
+
343
+ # -- Notifier management --------------------------------------------------
344
+
345
+ def cmd_add_notifier(req)
346
+ agent_id = req[:agent_id]
347
+ name = req[:notifier_name]&.to_sym
348
+ config = req[:config] || {}
349
+ notifier_type = config[:type] || name
350
+ raise_if_notifier_privileged!(name, notifier_type, action: "add")
351
+ agent = @store.get(agent_id)
352
+ raise "Agent not found: #{agent_id}" unless agent
353
+ agent.set_notifier_config(name, config)
354
+ @notification_dispatcher.add_agent_notifier(agent_id, name)
355
+ nil
356
+ end
357
+
358
+ def cmd_remove_notifier(req)
359
+ agent_id = req[:agent_id]
360
+ name = req[:notifier_name]&.to_sym
361
+ agent = @store.get(agent_id)
362
+ raise "Agent not found: #{agent_id}" unless agent
363
+ existing_config = agent.notifier_config(name) || {}
364
+ notifier_type = existing_config[:type] || name
365
+ raise_if_notifier_privileged!(name, notifier_type, action: "remove")
366
+ @notification_dispatcher.remove_agent_notifier(agent_id, name)
367
+ agent.remove_notifier(name)
368
+ nil
369
+ end
370
+
371
+ def cmd_list_notifiers(req)
372
+ agent_id = req[:agent_id]
373
+ agent = @store.get(agent_id)
374
+ raise "Agent not found: #{agent_id}" unless agent
375
+
376
+ notifiers = agent.notifiers.reject { |name, config| notifier_hidden?(name, config) }
377
+ .map { |name, config| {name:, type: config[:type] || name} }
378
+
379
+ {notifiers:}
380
+ end
381
+
382
+ def cmd_get_privileged_notifiers(_req)
383
+ {
384
+ names: @config.notifications.select { |n| n.is_a?(Hash) && n[:privileged] == true && n[:name] }.map { it[:name].to_sym },
385
+ types: @config.notification_privileged_types
386
+ }
387
+ end
388
+
389
+ # -- Discovery -----------------------------------------------------------
390
+
391
+ def cmd_discover_monitors(req)
392
+ monitors = Monitor.registered.each_with_object([]) do |(type, klass), list|
393
+ next if monitor_type_hidden?(type)
394
+
395
+ probe_klass = klass.probe_class
396
+ entry = {
397
+ type: type.to_s,
398
+ description: klass.description,
399
+ required_config: klass.required_config,
400
+ event_types: klass.event_types
401
+ }
402
+
403
+ if probe_klass
404
+ entry[:probe] = {
405
+ description: probe_klass.respond_to?(:description) ? probe_klass.description : nil
406
+ }
407
+ end
408
+
409
+ list << entry
410
+ end
411
+
412
+ result = {available_monitors: monitors}
413
+
414
+ # Include probe-detected configs from the agent's stored environment
415
+ agent_id = req[:agent_id]
416
+ if agent_id
417
+ agent = @store.get(agent_id)
418
+ if agent&.environment
419
+ detected = Monitor.detect_all(environment: agent.environment)
420
+ detected = filter_privileged_detected(detected)
421
+ result[:detected] = detected unless detected.empty?
422
+ end
423
+ end
424
+
425
+ result
426
+ end
427
+
428
+ def cmd_discover_goals(_req)
429
+ goals = Goal.registered.each_with_object([]) do |(type, klass), list|
430
+ entry = {type: type.to_s}
431
+ entry[:description] = klass.description if klass.description
432
+ entry[:required_config] = klass.required_config unless klass.required_config.empty?
433
+ list << entry
434
+ end
435
+
436
+ {available_goals: goals}
437
+ end
438
+
439
+ def cmd_discover_notifiers(_req)
440
+ privileged_types = @config.notification_privileged_types || []
441
+
442
+ notifiers = Notifier.registered.each_with_object([]) do |(type, klass), list|
443
+ next if privileged_types.include?(type.to_sym)
444
+
445
+ entry = {type: type.to_s}
446
+ entry[:description] = klass.description if klass.respond_to?(:description) && klass.description
447
+ entry[:stateful] = true if klass.method_defined?(:stateful?) && klass.new(state_store: nil).stateful?
448
+ list << entry
449
+ rescue => e
450
+ Superkick.logger.debug("control") { "Error probing notifier #{type}: #{e.message}" }
451
+ list << {type: type.to_s}
452
+ end
453
+
454
+ {available_notifiers: notifiers}
455
+ end
456
+
457
+ # -- Agent claim/unclaim -------------------------------------------------
458
+
459
+ def cmd_claim_agent(req)
460
+ agent_id = req[:agent_id]
461
+ agent = @store.get(agent_id)
462
+ raise "Agent not found: #{agent_id}" unless agent
463
+ raise "Not a spawned agent" unless agent.spawn_info
464
+ raise "Agent already claimed" if agent.claimed?
465
+
466
+ agent.claim!
467
+ @supervisor.pause_goal_checker(agent_id)
468
+
469
+ dispatch_notification(
470
+ event: {event_type: :agent_claimed, monitor_type: :spawner,
471
+ monitor_name: agent.spawn_info[:spawner_name] || :system},
472
+ agent_id:,
473
+ message: "Agent #{agent_id} claimed by user"
474
+ )
475
+ nil
476
+ end
477
+
478
+ def cmd_unclaim_agent(req)
479
+ agent_id = req[:agent_id]
480
+ agent = @store.get(agent_id)
481
+ raise "Agent not found: #{agent_id}" unless agent
482
+ raise "Agent is not claimed" unless agent.claimed?
483
+
484
+ agent.unclaim!
485
+ @supervisor.resume_goal_checker(agent_id)
486
+
487
+ dispatch_notification(
488
+ event: {event_type: :agent_unclaimed, monitor_type: :spawner,
489
+ monitor_name: agent.spawn_info&.dig(:spawner_name) || :system},
490
+ agent_id:,
491
+ message: "Agent #{agent_id} released to autonomous operation"
492
+ )
493
+ nil
494
+ end
495
+
496
+ # -- Spawner management --------------------------------------------------
497
+
498
+ def cmd_list_spawners(_req)
499
+ {spawners: @supervisor.spawner_status}
500
+ end
501
+
502
+ def cmd_stop_spawner(req)
503
+ name = req[:spawner_name]&.to_sym
504
+ raise "Missing spawner_name" unless name
505
+ @supervisor.stop_spawner(name)
506
+ nil
507
+ end
508
+
509
+ def cmd_start_spawner(req)
510
+ name = req[:spawner_name]&.to_sym
511
+ raise "Missing spawner_name" unless name
512
+ config = @config.spawners[name]
513
+ raise "Unknown spawner: #{name}" unless config
514
+ @supervisor.enqueue(:start_spawner, monitor_name: name, config:)
515
+ nil
516
+ end
517
+
518
+ def cmd_terminate_agent(req)
519
+ agent_id = req[:agent_id]
520
+ agent = @store.get(agent_id)
521
+ raise "Agent not found: #{agent_id}" unless agent
522
+ raise "Not a spawned agent" unless agent.spawn_info
523
+ raise "No agent spawner available" unless @agent_spawner
524
+
525
+ spawner_name = agent.spawn_info[:spawner_name]
526
+ @agent_spawner.terminate(agent_id)
527
+
528
+ dispatch_notification(
529
+ event: {event_type: :agent_terminated, monitor_type: :spawner, monitor_name: spawner_name || :system},
530
+ agent_id:,
531
+ message: "Agent #{agent_id} manually terminated"
532
+ )
533
+ nil
534
+ end
535
+
536
+ def cmd_signal_goal(req)
537
+ agent_id = req[:agent_id]
538
+ status = req[:status]&.to_sym
539
+ raise "Missing agent_id" unless agent_id
540
+ raise "Missing status" unless status
541
+ raise "Invalid status: #{status}" unless %i[completed failed errored in_progress].include?(status)
542
+
543
+ agent = @store.get(agent_id)
544
+ raise "Agent not found: #{agent_id}" unless agent
545
+
546
+ agent.set_goal_status(status)
547
+ agent.set_goal_summary(req[:summary]) if req[:summary]
548
+ @supervisor.signal_goal(agent_id, status)
549
+
550
+ # Fire agent_blocked notification when AI signals errored status
551
+ if status == :errored && agent.spawn_info
552
+ spawner_name = agent.spawn_info[:spawner_name]
553
+ dispatch_notification(
554
+ event: {event_type: :agent_blocked, monitor_type: :spawner, monitor_name: spawner_name || :system},
555
+ agent_id:,
556
+ message: "Agent #{agent_id} needs help (signaled errored)"
557
+ )
558
+ end
559
+
560
+ Superkick.logger.info("control_server") { "Agent #{agent_id} signaled: #{status}" }
561
+ nil
562
+ end
563
+
564
+ # -- Approval gates ------------------------------------------------------
565
+
566
+ def cmd_list_approvals(_req)
567
+ raise "No approval store available" unless @approval_store
568
+
569
+ approvals = @approval_store.list.map do |entry|
570
+ {
571
+ id: entry[:id],
572
+ agent_id: entry[:agent_id],
573
+ spawner_name: entry[:spawner_config][:name],
574
+ event_type: entry[:event][:event_type].to_s,
575
+ created_at: entry[:created_at]
576
+ }
577
+ end
578
+ {approvals:}
579
+ end
580
+
581
+ def cmd_approve(req)
582
+ approval_id = req[:approval_id]
583
+ raise "Missing approval_id" unless approval_id
584
+ raise "No approval store available" unless @approval_store
585
+ raise "No agent spawner available" unless @agent_spawner
586
+
587
+ entry = @approval_store.take(approval_id)
588
+ raise "No pending approval: #{approval_id}" unless entry
589
+
590
+ result = @agent_spawner.spawn(event: entry[:event], spawner_config: entry[:spawner_config])
591
+ {status: result[:status], agent_id: result[:agent_id]}
592
+ end
593
+
594
+ def cmd_reject(req)
595
+ approval_id = req[:approval_id]
596
+ raise "Missing approval_id" unless approval_id
597
+ raise "No approval store available" unless @approval_store
598
+
599
+ reason = req[:reason]
600
+ entry = @approval_store.reject(approval_id, reason:)
601
+ raise "No pending approval: #{approval_id}" unless entry
602
+
603
+ detail = reason ? " (#{reason})" : ""
604
+ Superkick.logger.info("control_server") { "Rejected spawn approval: #{approval_id}#{detail}" }
605
+ nil
606
+ end
607
+
608
+ def cmd_clear_rejection(req)
609
+ approval_id = req[:approval_id]
610
+ raise "Missing approval_id" unless approval_id
611
+ raise "No approval store available" unless @approval_store
612
+
613
+ @approval_store.clear_rejection(approval_id)
614
+ Superkick.logger.info("control_server") { "Cleared rejection: #{approval_id}" }
615
+ nil
616
+ end
617
+
618
+ def cmd_clear_all_rejections(_req)
619
+ raise "No approval store available" unless @approval_store
620
+
621
+ @approval_store.clear_all_rejections
622
+ Superkick.logger.info("control_server") { "Cleared all rejections" }
623
+ nil
624
+ end
625
+
626
+ def cmd_list_rejections(_req)
627
+ raise "No approval store available" unless @approval_store
628
+
629
+ {rejections: @approval_store.rejections}
630
+ end
631
+
632
+ # -- Cost tracking -------------------------------------------------------
633
+
634
+ def cmd_report_cost(req)
635
+ agent = @store.get(req[:agent_id])
636
+ raise "Agent not found" unless agent
637
+
638
+ source = req[:source]&.to_sym || :unknown
639
+
640
+ if source == :mcp_report
641
+ # MCP reports cumulative totals — compute delta
642
+ prev = agent.cost.to_h
643
+ delta_in = (req[:tokens_in] || 0) - prev[:total_tokens_in]
644
+ delta_out = (req[:tokens_out] || 0) - prev[:total_tokens_out]
645
+ delta_usd = (req[:cost_usd] || 0.0) - prev[:total_cost_usd]
646
+
647
+ # Only record positive deltas (avoid negative corrections)
648
+ agent.cost.record(
649
+ tokens_in: [delta_in, 0].max,
650
+ tokens_out: [delta_out, 0].max,
651
+ cost_usd: [delta_usd, 0.0].max,
652
+ source:
653
+ )
654
+ else
655
+ # PTY scrape reports incremental samples
656
+ agent.cost.record(
657
+ tokens_in: req[:tokens_in] || 0,
658
+ tokens_out: req[:tokens_out] || 0,
659
+ cost_usd: req[:cost_usd] || 0.0,
660
+ source:
661
+ )
662
+ end
663
+
664
+ check_budget(agent)
665
+ nil
666
+ end
667
+
668
+ def cmd_get_agent_cost(req)
669
+ agent = @store.get(req[:agent_id])
670
+ raise "Agent not found: #{req[:agent_id]}" unless agent
671
+
672
+ agent.cost.to_h
673
+ end
674
+
675
+ def cmd_get_cost_summary(_req)
676
+ agents = []
677
+ @store.each do |agent|
678
+ cost_data = agent.cost.to_h
679
+ next if cost_data[:total_tokens_in] == 0 && cost_data[:total_cost_usd] == 0
680
+
681
+ entry = {agent_id: agent.id, cost: cost_data}
682
+ entry[:spawner_name] = agent.spawn_info[:spawner_name] if agent.spawn_info
683
+ entry[:goal_status] = agent.goal_status if agent.goal_status
684
+ agents << entry
685
+ end
686
+ {agents:}
687
+ end
688
+
689
+ # -- Team operations -----------------------------------------------------
690
+
691
+ def cmd_post_update(req)
692
+ agent_id = req[:agent_id]
693
+ agent = @store.get(agent_id)
694
+ raise "Agent not found: #{agent_id}" unless agent
695
+
696
+ if req[:target_agent_id]
697
+ # Directed message — validate same team, inject into target
698
+ raise "Agent is not on a team" unless agent.team_id
699
+
700
+ target = @store.get(req[:target_agent_id])
701
+ raise "Target not found: #{req[:target_agent_id]}" unless target
702
+ raise "Target is not on the same team" unless target.team_id == agent.team_id
703
+
704
+ injection_event = {
705
+ event_type: :teammate_message,
706
+ monitor_type: :team_log,
707
+ monitor_name: :team_log,
708
+ sender_agent_id: agent_id,
709
+ sender_role: agent.team_role.to_s,
710
+ message: req[:message],
711
+ time: Time.now.strftime("%H:%M:%S"),
712
+ injection_priority: :high,
713
+ injection_ttl: 900
714
+ }
715
+ @injector.inject(agent_id: req[:target_agent_id], event: injection_event)
716
+
717
+ dispatch_notification(
718
+ event: {event_type: :teammate_message, monitor_type: :team, monitor_name: :team,
719
+ target_agent_id: req[:target_agent_id], team_id: agent.team_id},
720
+ agent_id:,
721
+ message: "#{agent_id} sent message to #{req[:target_agent_id]}: #{req[:message].slice(0, 100)}"
722
+ )
723
+ else
724
+ # Broadcast update
725
+ event_type = (req[:kind]&.to_sym == :blocker) ? :teammate_blocker : :agent_update
726
+ dispatch_notification(
727
+ event: {event_type:, monitor_type: :agent, monitor_name: :agent,
728
+ kind: req[:kind]&.to_sym, team_id: agent.team_id},
729
+ agent_id:,
730
+ message: req[:message]
731
+ )
732
+ end
733
+
734
+ nil
735
+ end
736
+
737
+ def cmd_post_team_message(req)
738
+ team_id = req[:team_id]
739
+ raise "Missing team_id" unless team_id
740
+ raise "Missing message" unless req[:message]
741
+
742
+ members = @store.team(team_id)
743
+ raise "No agents found for team: #{team_id}" if members.empty?
744
+
745
+ # Inject the message into every agent on the team
746
+ members.each do |agent|
747
+ injection_event = {
748
+ event_type: :teammate_message,
749
+ monitor_type: :team_log,
750
+ monitor_name: :team_log,
751
+ sender_agent_id: "operator",
752
+ sender_role: "operator",
753
+ message: req[:message],
754
+ time: Time.now.strftime("%H:%M:%S"),
755
+ injection_priority: :high,
756
+ injection_ttl: 900
757
+ }
758
+ @injector.inject(agent_id: agent.id, event: injection_event)
759
+ end
760
+
761
+ # Write to team log so it's visible in `team watch`
762
+ if @team_log_store
763
+ log = @team_log_store.get(team_id)
764
+ log.append(
765
+ agent_id: "operator",
766
+ agent_role: :operator,
767
+ category: :message,
768
+ message: req[:message]
769
+ )
770
+ end
771
+
772
+ {delivered_to: members.size}
773
+ end
774
+
775
+ def cmd_team_status(req)
776
+ team_id = req[:team_id]
777
+ unless team_id
778
+ agent_id = req[:agent_id]
779
+ agent = @store.get(agent_id)
780
+ raise "Agent not found: #{agent_id}" unless agent
781
+ raise "Agent is not on a team" unless agent.team_id
782
+ team_id = agent.team_id
783
+ end
784
+ raise "No team log store available" unless @team_log_store
785
+ log = @team_log_store.get(team_id)
786
+
787
+ teammates = @store.team(team_id).map do |a|
788
+ entry = {agent_id: a.id, team_role: a.team_role, goal_status: a.goal_status}
789
+ entry[:role] = a.role if a.role
790
+ entry
791
+ end
792
+
793
+ result = {team_id:, teammates:}
794
+
795
+ if log
796
+ if req[:full_log]
797
+ result[:entries] = log.entries(since: req[:since]).map(&:to_h)
798
+ else
799
+ summary = log.summary
800
+ result[:latest] = summary[:latest_by_agent].map(&:to_h)
801
+ result[:unresolved_blockers] = summary[:unresolved_blockers].map(&:to_h)
802
+ end
803
+ end
804
+
805
+ result
806
+ end
807
+
808
+ def cmd_list_teammates(req)
809
+ agent_id = req[:agent_id]
810
+ agent = @store.get(agent_id)
811
+ raise "Agent not found: #{agent_id}" unless agent
812
+ raise "Agent is not on a team" unless agent.team_id
813
+
814
+ teammates = @store.team(agent.team_id).map do |a|
815
+ entry = {agent_id: a.id, team_role: a.team_role, goal_status: a.goal_status}
816
+ entry[:role] = a.role if a.role
817
+ entry[:claimed_at] = a.claimed_at if a.claimed_at
818
+
819
+ if @team_log_store
820
+ log = @team_log_store.get(agent.team_id)
821
+ if log
822
+ latest = log.entries.select { it.agent_id == a.id }.last
823
+ entry[:latest_update] = latest.to_h if latest
824
+ end
825
+ end
826
+
827
+ entry
828
+ end
829
+
830
+ {team_id: agent.team_id, teammates:}
831
+ end
832
+
833
+ def cmd_spawn_worker(req)
834
+ agent_id = req[:agent_id]
835
+ raise "Missing agent_id" unless agent_id
836
+ raise "No agent spawner available" unless @agent_spawner
837
+
838
+ agent = @store.get(agent_id)
839
+ raise "Agent not found: #{agent_id}" unless agent
840
+ raise "Agent is not on a team" unless agent.team_id
841
+
842
+ team_id = agent.team_id
843
+ repository_name = req[:repository]
844
+ task = req[:task]
845
+ agent_suffix = req[:agent_suffix]
846
+ raise "Missing repository" unless repository_name
847
+ raise "Missing task" unless task
848
+ raise "Missing agent_suffix" unless agent_suffix
849
+
850
+ # Enforce max_workers_per_team
851
+ current_workers = @store.team(team_id).count { it.team_role == :worker }
852
+ max_workers = @config.max_workers_per_team
853
+ raise "Team worker limit reached (#{max_workers})" if current_workers >= max_workers
854
+
855
+ # Validate repository exists in source
856
+ repository = @config.repository_source.find_by_name(repository_name)
857
+ raise "Unknown repository: #{repository_name}" unless repository
858
+
859
+ worker_id = "#{team_id}-#{agent_suffix}"
860
+ raise "Agent #{worker_id} already exists" if @store.has?(worker_id)
861
+
862
+ # Build worker event
863
+ goal_config = req[:goal] || {type: :agent_signal}
864
+ goal_config[:type] = goal_config[:type].to_sym if goal_config[:type]
865
+
866
+ worker_event = {
867
+ event_type: :worker_spawned,
868
+ agent_id: worker_id,
869
+ team_id:,
870
+ team_role: :worker,
871
+ role: req[:role],
872
+ lead_agent_id: agent_id,
873
+ repository_name: repository_name.to_s,
874
+ task:,
875
+ depends_on: req[:depends_on]
876
+ }
877
+
878
+ # Auto-attach team_log monitor; lead-provided monitors merge on top
879
+ auto_monitors = {team_log: {type: :team_log, team_id:}}
880
+ explicit_monitors = req[:monitors] || {}
881
+ worker_event[:_spawn_monitors] = auto_monitors.merge(explicit_monitors)
882
+
883
+ # Forward notifiers from the lead
884
+ worker_event[:_spawn_notifiers] = req[:notifiers] if req[:notifiers]&.any?
885
+
886
+ # Add monitor names to template context (names only, no config/secrets)
887
+ all_monitor_names = worker_event[:_spawn_monitors].keys.map(&:to_s)
888
+ worker_event[:monitor_names] = all_monitor_names
889
+
890
+ # Determine spawner config for the worker
891
+ spawner_config = build_worker_spawner_config(agent, repository, goal_config)
892
+
893
+ result = @agent_spawner.spawn(event: worker_event, spawner_config:)
894
+
895
+ if result[:status] == :spawned
896
+ dispatch_notification(
897
+ event: {event_type: :worker_spawned, monitor_type: :team, monitor_name: :team,
898
+ team_id:, repository_name:},
899
+ agent_id: worker_id,
900
+ message: "Worker #{worker_id} spawned for repository #{repository_name} by #{agent_id}"
901
+ )
902
+ end
903
+
904
+ {status: result[:status], agent_id: worker_id}
905
+ end
906
+
907
+ def cmd_spawn_worker_by_team(req)
908
+ team_id = req[:team_id]
909
+ raise "Missing team_id" unless team_id
910
+ raise "No agent spawner available" unless @agent_spawner
911
+
912
+ # Find the team lead (or any team member) to derive spawner config
913
+ team_agents = @store.team(team_id)
914
+ raise "No agents found for team: #{team_id}" if team_agents.empty?
915
+ lead_agent = team_agents.find { it.team_role == :lead } || team_agents.first
916
+
917
+ repository_name = req[:repository]
918
+ task = req[:task]
919
+ raise "Missing repository" unless repository_name
920
+ raise "Missing task" unless task
921
+
922
+ # Enforce max_workers_per_team
923
+ current_workers = team_agents.count { it.team_role == :worker }
924
+ max_workers = @config.max_workers_per_team
925
+ raise "Team worker limit reached (#{max_workers})" if current_workers >= max_workers
926
+
927
+ # Validate repository exists in source
928
+ repository = @config.repository_source.find_by_name(repository_name)
929
+ raise "Unknown repository: #{repository_name}" unless repository
930
+
931
+ # Generate agent ID from role + random suffix
932
+ worker_id = generate_worker_id(team_id, req[:role])
933
+ raise "Agent #{worker_id} already exists" if @store.has?(worker_id)
934
+
935
+ # Build worker event
936
+ goal_config = req[:goal] || {type: :agent_signal}
937
+ goal_config[:type] = goal_config[:type].to_sym if goal_config[:type]
938
+
939
+ worker_event = {
940
+ event_type: :worker_spawned,
941
+ agent_id: worker_id,
942
+ team_id:,
943
+ team_role: :worker,
944
+ role: req[:role],
945
+ lead_agent_id: lead_agent.id,
946
+ repository_name: repository_name.to_s,
947
+ task:,
948
+ depends_on: req[:depends_on]
949
+ }
950
+
951
+ # Auto-attach team_log monitor; merge user-provided monitors on top
952
+ spawn_monitors = {team_log: {type: :team_log, team_id:}}
953
+ if req[:monitors].is_a?(Hash)
954
+ req[:monitors].each { |k, v| spawn_monitors[k.to_sym] = v }
955
+ end
956
+ worker_event[:_spawn_monitors] = spawn_monitors
957
+
958
+ # Attach user-provided notifiers
959
+ if req[:notifiers].is_a?(Hash)
960
+ spawn_notifiers = {}
961
+ req[:notifiers].each { |k, v| spawn_notifiers[k.to_sym] = v }
962
+ worker_event[:_spawn_notifiers] = spawn_notifiers
963
+ end
964
+
965
+ spawner_config = build_worker_spawner_config(lead_agent, repository, goal_config)
966
+ result = @agent_spawner.spawn(event: worker_event, spawner_config:)
967
+
968
+ if result[:status] == :spawned
969
+ dispatch_notification(
970
+ event: {event_type: :worker_spawned, monitor_type: :team, monitor_name: :team,
971
+ team_id:, repository_name:},
972
+ agent_id: worker_id,
973
+ message: "Worker #{worker_id} spawned for repository #{repository_name} via CLI"
974
+ )
975
+ end
976
+
977
+ {status: result[:status], agent_id: worker_id}
978
+ end
979
+
980
+ # -- Repository discovery ------------------------------------------------
981
+
982
+ def cmd_discover_repositories(_req)
983
+ source = @config.repository_source
984
+ repositories = source.repositories.map do |name, repository|
985
+ entry = {name: name.to_s}
986
+ entry[:dependencies] = repository.dependencies if repository.dependencies.any?
987
+ entry[:path] = repository.path if repository.path
988
+ entry[:url] = repository.url if repository.url
989
+ entry[:version_control] = repository.version_control if repository.version_control
990
+ entry
991
+ end
992
+ {repositories:}
993
+ end
994
+
995
+ # -- Team artifacts ------------------------------------------------------
996
+
997
+ def cmd_publish_artifact(req)
998
+ agent_id = req[:agent_id]
999
+ agent = @store.get(agent_id)
1000
+ raise "Agent not found: #{agent_id}" unless agent
1001
+ raise "Agent is not on a team" unless agent.team_id
1002
+ raise "No artifact store available" unless @team_artifact_store
1003
+ raise "Missing name" unless req[:name]
1004
+ raise "Missing content" unless req[:content]
1005
+
1006
+ artifact = @team_artifact_store.publish(
1007
+ team_id: agent.team_id,
1008
+ author: agent_id,
1009
+ name: req[:name],
1010
+ content: req[:content]
1011
+ )
1012
+
1013
+ dispatch_notification(
1014
+ event: {event_type: :artifact_published, monitor_type: :agent, monitor_name: :agent,
1015
+ artifact_name: req[:name], team_id: agent.team_id},
1016
+ agent_id:,
1017
+ message: "Published artifact: #{req[:name]}"
1018
+ )
1019
+
1020
+ {name: artifact.name, author: artifact.author, updated_at: artifact.updated_at}
1021
+ end
1022
+
1023
+ def cmd_read_artifact(req)
1024
+ agent_id = req[:agent_id]
1025
+ agent = @store.get(agent_id)
1026
+ raise "Agent not found: #{agent_id}" unless agent
1027
+ raise "Agent is not on a team" unless agent.team_id
1028
+ raise "No artifact store available" unless @team_artifact_store
1029
+ raise "Missing author" unless req[:author]
1030
+ raise "Missing name" unless req[:name]
1031
+
1032
+ artifact = @team_artifact_store.get(
1033
+ team_id: agent.team_id,
1034
+ author: req[:author],
1035
+ name: req[:name]
1036
+ )
1037
+ raise "Artifact not found: #{req[:author]}/#{req[:name]}" unless artifact
1038
+
1039
+ {name: artifact.name, author: artifact.author, content: artifact.content,
1040
+ created_at: artifact.created_at, updated_at: artifact.updated_at}
1041
+ end
1042
+
1043
+ def cmd_list_artifacts(req)
1044
+ agent_id = req[:agent_id]
1045
+ agent = @store.get(agent_id)
1046
+ raise "Agent not found: #{agent_id}" unless agent
1047
+ raise "Agent is not on a team" unless agent.team_id
1048
+ raise "No artifact store available" unless @team_artifact_store
1049
+
1050
+ artifacts = @team_artifact_store.list(
1051
+ team_id: agent.team_id,
1052
+ author: req[:author]
1053
+ )
1054
+ {artifacts:}
1055
+ end
1056
+
1057
+ # -- Team artifacts (by team_id, for CLI) ---------------------------------
1058
+
1059
+ def cmd_list_team_artifacts(req)
1060
+ team_id = req[:team_id]
1061
+ raise "Missing team_id" unless team_id
1062
+ raise "No artifact store available" unless @team_artifact_store
1063
+
1064
+ artifacts = @team_artifact_store.list(
1065
+ team_id:,
1066
+ author: req[:author]
1067
+ )
1068
+ {artifacts:}
1069
+ end
1070
+
1071
+ def cmd_read_team_artifact(req)
1072
+ team_id = req[:team_id]
1073
+ raise "Missing team_id" unless team_id
1074
+ raise "No artifact store available" unless @team_artifact_store
1075
+ raise "Missing author" unless req[:author]
1076
+ raise "Missing name" unless req[:name]
1077
+
1078
+ artifact = @team_artifact_store.get(
1079
+ team_id:,
1080
+ author: req[:author],
1081
+ name: req[:name]
1082
+ )
1083
+ raise "Artifact not found: #{req[:author]}/#{req[:name]}" unless artifact
1084
+
1085
+ {name: artifact.name, author: artifact.author, content: artifact.content,
1086
+ created_at: artifact.created_at, updated_at: artifact.updated_at}
1087
+ end
1088
+
1089
+ # -- Privileged monitor queries ------------------------------------------
1090
+
1091
+ def cmd_get_privileged_monitors(_req)
1092
+ {
1093
+ names: @config.monitors.select { |_, config| config.is_a?(Hash) && config[:privileged] == true }.keys,
1094
+ types: @config.privileged_types
1095
+ }
1096
+ end
1097
+
1098
+ def serialize_agent(agent)
1099
+ visible = agent.monitors.reject { |name, config| monitor_hidden?(name, config) }
1100
+ hash = {
1101
+ agent_id: agent.id,
1102
+ registered_at: agent.registered_at,
1103
+ last_notified: agent.last_notified_at,
1104
+ monitor_count: visible.size,
1105
+ has_buffer: !agent.buffer_socket_path.nil?,
1106
+ has_output_log: !agent.output_log_path.nil?,
1107
+ has_attach: !agent.attach_socket_path.nil?,
1108
+ output_log_path: agent.output_log_path,
1109
+ recording_path: agent.recording_path
1110
+ }
1111
+ hash[:spawn_info] = agent.spawn_info if agent.spawn_info
1112
+ hash[:goal_status] = agent.goal_status if agent.goal_status
1113
+ hash[:claimed_at] = agent.claimed_at if agent.claimed_at
1114
+ hash[:team_id] = agent.team_id if agent.team_id
1115
+ hash[:team_role] = agent.team_role if agent.team_role
1116
+ hash[:role] = agent.role if agent.role
1117
+ cost_data = agent.cost.to_h
1118
+ hash[:cost] = cost_data if cost_data[:total_tokens_in] > 0 || cost_data[:total_cost_usd] > 0
1119
+ hash
1120
+ end
1121
+
1122
+ # Returns true when a monitor should be hidden from AI-facing responses.
1123
+ def monitor_hidden?(name, config)
1124
+ monitor_type = config.is_a?(Hash) ? (config[:type] || name) : name
1125
+ @config.monitor_privileged?(name) || @config.type_privileged?(monitor_type)
1126
+ end
1127
+
1128
+ def monitor_type_hidden?(type)
1129
+ @config.type_privileged?(type)
1130
+ end
1131
+
1132
+ def collect_environment_actions
1133
+ Monitor.all_environment_actions
1134
+ end
1135
+
1136
+ def filter_privileged_detected(detected)
1137
+ priv_names = @config.monitors.select { |_, config| config.is_a?(Hash) && config[:privileged] == true }.keys
1138
+ priv_types = @config.privileged_types || []
1139
+ detected.reject do |name, config|
1140
+ monitor_type = config.is_a?(Hash) ? (config[:type] || name) : name
1141
+ priv_names.include?(name) || priv_types.include?(monitor_type.to_sym)
1142
+ end
1143
+ end
1144
+
1145
+ def raise_if_privileged!(name, type, action:)
1146
+ configuration = @config
1147
+
1148
+ if configuration.monitor_privileged?(name)
1149
+ raise "Cannot #{action} monitor '#{name}': it is a privileged monitor (protected by name)"
1150
+ end
1151
+
1152
+ if configuration.type_privileged?(type)
1153
+ raise "Cannot #{action} monitor '#{name}' (type '#{type}'): monitors of type '#{type}' are privileged (protected by type)"
1154
+ end
1155
+ end
1156
+
1157
+ # Returns true when a notifier should be hidden from AI-facing responses.
1158
+ def notifier_hidden?(name, config)
1159
+ notifier_type = config.is_a?(Hash) ? (config[:type] || name) : name
1160
+ @config.notifier_privileged?(name) || @config.notifier_type_privileged?(notifier_type)
1161
+ end
1162
+
1163
+ def raise_if_notifier_privileged!(name, type, action:)
1164
+ configuration = @config
1165
+
1166
+ if configuration.notifier_privileged?(name)
1167
+ raise "Cannot #{action} notifier '#{name}': it is a privileged notifier (protected by name)"
1168
+ end
1169
+
1170
+ if configuration.notifier_type_privileged?(type)
1171
+ raise "Cannot #{action} notifier '#{name}' (type '#{type}'): notifiers of type '#{type}' are privileged (protected by type)"
1172
+ end
1173
+ end
1174
+
1175
+ def check_budget(agent)
1176
+ return unless @budget_checker
1177
+
1178
+ violations = @budget_checker.check(agent)
1179
+ return if violations.empty?
1180
+
1181
+ violations.each do |v|
1182
+ case v[:action]
1183
+ when :warning
1184
+ notify_budget(:budget_warning, agent, v)
1185
+ when :exceeded
1186
+ notify_budget(:budget_exceeded, agent, v)
1187
+ enforce_budget(agent, v) if hard_enforcement?(agent)
1188
+ end
1189
+ end
1190
+ end
1191
+
1192
+ def notify_budget(event_type, agent, violation)
1193
+ dispatch_notification(
1194
+ event: {
1195
+ event_type:,
1196
+ monitor_type: :cost,
1197
+ monitor_name: :budget,
1198
+ budget: violation[:budget],
1199
+ spent: violation[:spent].round(2)
1200
+ },
1201
+ agent_id: agent.id,
1202
+ message: "Agent #{agent.id} #{violation[:level]} budget: " \
1203
+ "$#{violation[:spent].round(2)} / $#{violation[:budget]} " \
1204
+ "(#{violation[:action]})"
1205
+ )
1206
+ end
1207
+
1208
+ def hard_enforcement?(agent)
1209
+ return false unless agent.spawn_info
1210
+ spawner_name = agent.spawn_info[:spawner_name]&.to_sym
1211
+ @config.spawners.dig(spawner_name, :budget, :enforce)&.to_s == "hard"
1212
+ end
1213
+
1214
+ def enforce_budget(agent, violation)
1215
+ case violation[:level]
1216
+ when :agent
1217
+ @agent_spawner&.terminate(agent.id)
1218
+ when :spawner
1219
+ spawner_name = agent.spawn_info[:spawner_name]&.to_sym
1220
+ @supervisor.stop_spawner(spawner_name) if spawner_name
1221
+ when :global
1222
+ @config.spawners.each_key { @supervisor.stop_spawner(it) }
1223
+ end
1224
+ end
1225
+
1226
+ def build_worker_spawner_config(lead_agent, repository, goal_config)
1227
+ spawner_name = lead_agent.spawn_info&.dig(:spawner_name)
1228
+ spawner_cfg = spawner_name ? (@config.spawners[spawner_name.to_sym] || {}) : {}
1229
+ worker_cfg = spawner_cfg.dig(:team, :worker) || {}
1230
+
1231
+ # Resolve driver: team.worker.driver merged on top of spawner driver
1232
+ driver = Driver.merge_driver(spawner_cfg[:driver], worker_cfg[:driver])
1233
+ max_duration = worker_cfg[:max_duration] || spawner_cfg[:max_duration]
1234
+
1235
+ config = {
1236
+ name: spawner_name,
1237
+ driver:,
1238
+ repository: repository.name.to_s,
1239
+ goal: goal_config,
1240
+ prompt_template: "team/worker_kickoff"
1241
+ }
1242
+ config[:max_duration] = max_duration if max_duration
1243
+ config[:budget] = worker_cfg[:budget] if worker_cfg[:budget]
1244
+ config
1245
+ end
1246
+
1247
+ # Forward a buffer command to the agent via the buffer client.
1248
+ def proxy_buffer(agent_id, command, **extra_params)
1249
+ response = @buffer_client.request(agent_id, command, **extra_params.compact)
1250
+ raise response[:error] if response && response[:ok] == false && response[:error]
1251
+
1252
+ response
1253
+ end
1254
+
1255
+ def generate_worker_id(team_id, role)
1256
+ suffix = if role
1257
+ slugified = role.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")[0, 20]
1258
+ random = SecureRandom.hex(3)
1259
+ "#{slugified}-#{random}"
1260
+ else
1261
+ SecureRandom.hex(3)
1262
+ end
1263
+ "#{team_id}-#{suffix}"
1264
+ end
1265
+
1266
+ def dispatch_notification(...)
1267
+ @notification_dispatcher.dispatch(...)
1268
+ end
1269
+ end
1270
+ end
1271
+ end