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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Superkick
6
+ # Orchestrates server startup, signal handling, and shutdown.
7
+ #
8
+ # Usage:
9
+ # Superkick::Server.start(daemonize: false)
10
+ class Server
11
+ def self.start(daemonize: false)
12
+ new(daemonize:).run
13
+ end
14
+
15
+ def initialize(daemonize: false)
16
+ @daemonize = daemonize
17
+ end
18
+
19
+ def run
20
+ check_already_running!
21
+
22
+ daemonize! if @daemonize
23
+
24
+ FileUtils.mkdir_p(Superkick.config.base_dir)
25
+ write_pid_file
26
+
27
+ Superkick.logger.info("server") { "Superkick server starting (v#{VERSION})" }
28
+
29
+ Superkick.load_config!
30
+ Superkick.logger.level = Superkick.config.log_level
31
+
32
+ store = AgentStore.new
33
+ buffer_client = Buffer.client_from(store:)
34
+ attach_relay_store = build_attach_relay_store
35
+ recover_agents(store, buffer_client)
36
+ notifier_state_store = NotifierStateStore::Memory.new
37
+ team_log_store = Team::LogStore.new
38
+ team_log_notifier = Team::LogNotifier.new(team_log_store:, state_store: notifier_state_store)
39
+ notification_dispatcher = NotificationDispatcher.new(
40
+ store:, state_store: notifier_state_store,
41
+ internal_notifiers: [[team_log_notifier, nil]]
42
+ )
43
+ injector = Injector.new(store:, notification_dispatcher:, buffer_client:)
44
+ team_artifact_store = Team::ArtifactStore.new
45
+ approval_store = Spawn::ApprovalStore.new
46
+ budget_checker = BudgetChecker.new(store:)
47
+ supervisor = Supervisor.new(
48
+ store:, injector:, buffer_client:,
49
+ approval_store:,
50
+ team_log_store:,
51
+ notification_dispatcher:
52
+ )
53
+ agent_spawner = Spawn::AgentSpawner.new(store:, notification_dispatcher:, supervisor:,
54
+ runtime: Superkick.config.agent_runtime)
55
+ supervisor.agent_spawner = agent_spawner
56
+ control_server = Control::Server.new(
57
+ store:,
58
+ injector:,
59
+ supervisor:,
60
+ buffer_client:,
61
+ agent_spawner:,
62
+ approval_store:,
63
+ budget_checker:,
64
+ team_log_store:,
65
+ team_artifact_store:,
66
+ notification_dispatcher:,
67
+ attach_relay_store:
68
+ )
69
+
70
+ validate_workflow_configs
71
+
72
+ supervisor.start
73
+ supervisor.restore_from_registry
74
+ supervisor.start_spawners
75
+
76
+ control_server.start
77
+ Superkick.logger.info("server") { "Control server listening on #{Superkick.config.socket_path}" }
78
+
79
+ wait_for_signals(control_server, supervisor)
80
+ rescue AlreadyRunning => e
81
+ warn "superkick: #{e.message}"
82
+ exit(1)
83
+ ensure
84
+ cleanup
85
+ end
86
+
87
+ private
88
+
89
+ AlreadyRunning = Class.new(StandardError)
90
+
91
+ def check_already_running!
92
+ return unless File.exist?(Superkick.config.pid_path)
93
+
94
+ pid = File.read(Superkick.config.pid_path).strip.to_i
95
+ return if pid <= 0
96
+
97
+ begin
98
+ Process.kill(0, pid)
99
+ raise AlreadyRunning, "Server already running (PID #{pid})"
100
+ rescue Errno::ESRCH
101
+ # Stale PID file — remove and continue
102
+ FileUtils.rm_f(Superkick.config.pid_path)
103
+ rescue Errno::EPERM
104
+ raise AlreadyRunning, "Server already running (PID #{pid})"
105
+ end
106
+ end
107
+
108
+ def write_pid_file
109
+ FileUtils.mkdir_p(File.dirname(Superkick.config.pid_path))
110
+ File.write(Superkick.config.pid_path, "#{Process.pid}\n")
111
+ end
112
+
113
+ def daemonize!
114
+ pid = fork
115
+ exit(0) if pid # parent exits
116
+
117
+ Process.setsid
118
+
119
+ pid = fork
120
+ exit(0) if pid # first child exits
121
+
122
+ FileUtils.mkdir_p(Superkick.config.base_dir)
123
+
124
+ $stdin.reopen(File::NULL)
125
+ $stdout.reopen(Superkick.config.log_path, "a")
126
+ $stderr.reopen(Superkick.config.log_path, "a")
127
+ $stdout.sync = true
128
+ $stderr.sync = true
129
+ end
130
+
131
+ def wait_for_signals(control_server, supervisor)
132
+ signal_queue = Queue.new
133
+ %w[TERM INT].each { |sig| Signal.trap(sig) { signal_queue << sig } }
134
+
135
+ Superkick.logger.info("server") { "Superkick server ready" }
136
+
137
+ loop do
138
+ break unless signal_queue.empty?
139
+ sleep 0.5
140
+ end
141
+
142
+ sig = signal_queue.pop
143
+ Superkick.logger.info("server") { "Received #{sig} — shutting down" }
144
+
145
+ control_server.stop
146
+ supervisor.stop
147
+ end
148
+
149
+ # Probe persisted buffer connections to check if agent processes survived
150
+ # a server restart. Keep live connections; detach and clean up stale ones.
151
+ def recover_agents(store, buffer_client)
152
+ store.each do |agent|
153
+ path = agent.buffer_socket_path
154
+ next unless path
155
+
156
+ if buffer_client.probe(agent.id)
157
+ Superkick.logger.info("server") { "Recovered buffer connection for agent #{agent.id}" }
158
+ else
159
+ Superkick.logger.info("server") { "Stale buffer connection for agent #{agent.id} — detaching" }
160
+ agent.detach_path(:buffer_socket_path)
161
+ FileUtils.rm_f(path)
162
+ end
163
+ end
164
+ end
165
+
166
+ def validate_workflow_configs
167
+ spawners = Superkick.config.spawners
168
+ return if spawners.empty?
169
+
170
+ cycles = Spawn::WorkflowValidator.validate(spawners)
171
+ cycles.each do |cycle|
172
+ names = cycle.map(&:to_s).join(" -> ")
173
+ involved = cycle.uniq
174
+
175
+ if involved.any? { |name| spawners.dig(name, :allow_cycles) }
176
+ Superkick.logger.warn("server") { "Workflow cycle detected (allowed): #{names}" }
177
+ else
178
+ Superkick.logger.error("server") { "Workflow cycle detected: #{names} — disabling involved spawners" }
179
+ involved.each { |name| spawners.delete(name) }
180
+ end
181
+ end
182
+
183
+ return if spawners.empty?
184
+
185
+ # Require max_iterations on cyclic spawners — without a limit, cycles recurse forever
186
+ missing = Spawn::WorkflowValidator.cyclic_spawners_without_iteration_limit(spawners)
187
+ missing.each do |name|
188
+ Superkick.logger.error("server") {
189
+ "Spawner #{name} has allow_cycles: true but no max_iterations — " \
190
+ "disabling to prevent infinite recursion. Set max_iterations on the spawner."
191
+ }
192
+ spawners.delete(name)
193
+ end
194
+ end
195
+
196
+ def build_attach_relay_store
197
+ config = {
198
+ replay_buffer_size: Superkick.config.attach_replay_buffer_size,
199
+ max_connections: Superkick.config.attach_max_connections,
200
+ rw_idle_timeout: Superkick.config.attach_rw_idle_timeout
201
+ }
202
+ Hosted::Attach::RelayStore.new(config:)
203
+ end
204
+
205
+ def cleanup
206
+ FileUtils.rm_f(Superkick.config.pid_path)
207
+ FileUtils.rm_f(Superkick.config.socket_path)
208
+ Superkick.logger.info("server") { "Superkick server stopped" }
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Records complete, timestamped agent sessions (input + output) in the
5
+ # asciicast v2 format. Recordings enable audit logging and visual playback
6
+ # via asciinema-player or `asciinema play`.
7
+ #
8
+ # Thread-safe — three PtyProxy threads (proxy_stdin, proxy_output,
9
+ # drain_inject_queue) write concurrently via Mutex.
10
+ #
11
+ # Lifecycle follows OutputLogger: create → start → record_* → close.
12
+ class SessionRecorder
13
+ MAX_SIZE = 100 * 1024 * 1024 # 100 MB default
14
+
15
+ def initialize(agent_id:, width: 120, height: 40, max_size: MAX_SIZE,
16
+ recordings_dir: Superkick.config.recordings_dir, store: nil)
17
+ @path = File.join(recordings_dir, "#{agent_id}.cast")
18
+ @agent_id = agent_id
19
+ @width = width
20
+ @height = height
21
+ @max_size = max_size
22
+ @store = store
23
+ @file = nil
24
+ @bytes_written = 0
25
+ @start_time = nil
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def start
30
+ FileUtils.mkdir_p(File.dirname(@path))
31
+ @file = File.open(@path, "ab")
32
+ @file.sync = true
33
+ @bytes_written = @file.size
34
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ write_header
36
+ self
37
+ end
38
+
39
+ def record_output(data)
40
+ record_event("o", data)
41
+ end
42
+
43
+ def record_input(data)
44
+ record_event("i", data)
45
+ end
46
+
47
+ def resize(width, height)
48
+ @mutex.synchronize do
49
+ return unless @file
50
+
51
+ @width = width
52
+ @height = height
53
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
54
+ line = JSON.generate([elapsed.round(6), "r", "#{width}x#{height}"]) + "\n"
55
+ rotate! if @bytes_written + line.bytesize > @max_size
56
+ @file.write(line)
57
+ @bytes_written += line.bytesize
58
+ end
59
+ end
60
+
61
+ def close
62
+ @mutex.synchronize do
63
+ @file&.close
64
+ @file = nil
65
+ end
66
+ @store&.upload(agent_id: @agent_id, path: @path)
67
+ end
68
+
69
+ attr_reader :path
70
+
71
+ private
72
+
73
+ def write_header
74
+ header = {
75
+ version: 2,
76
+ width: @width,
77
+ height: @height,
78
+ timestamp: Time.now.to_i,
79
+ env: {"TERM" => ENV.fetch("TERM", "xterm-256color")}
80
+ }
81
+ line = JSON.generate(header) + "\n"
82
+ @file.write(line)
83
+ @bytes_written += line.bytesize
84
+ end
85
+
86
+ def record_event(type, data)
87
+ @mutex.synchronize do
88
+ return unless @file
89
+
90
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
91
+ line = JSON.generate([elapsed.round(6), type, data.b.force_encoding("UTF-8")]) + "\n"
92
+ rotate! if @bytes_written + line.bytesize > @max_size
93
+ @file.write(line)
94
+ @bytes_written += line.bytesize
95
+ end
96
+ end
97
+
98
+ def rotate!
99
+ @file.close
100
+ rotated = "#{@path}.1"
101
+ FileUtils.rm_f(rotated)
102
+ File.rename(@path, rotated)
103
+ @file = File.open(@path, "ab")
104
+ @file.sync = true
105
+ @bytes_written = 0
106
+ end
107
+
108
+ # Abstract upload interface for completed recordings.
109
+ # Local mode uses Noop; hosted mode uses an HTTP implementation.
110
+ class Store
111
+ def upload(agent_id:, path:)
112
+ raise NotImplementedError, "#{self.class}#upload not implemented"
113
+ end
114
+ end
115
+
116
+ # Local mode — recordings stay on disk, no upload.
117
+ class Store::Noop < Store
118
+ def upload(agent_id:, path:) = nil
119
+ end
120
+
121
+ # Hosted mode — uploads the completed .cast file to the hosted server
122
+ # via multipart POST. Uses faraday-multipart to stream the file without
123
+ # loading it entirely into memory.
124
+ class Store::Http < Store
125
+ def initialize(server_url:, api_key:, connection: nil)
126
+ @server_url = server_url
127
+ @api_key = api_key
128
+ @connection = connection || build_connection
129
+ end
130
+
131
+ def upload(agent_id:, path:)
132
+ return unless File.exist?(path)
133
+
134
+ payload = {
135
+ file: Faraday::Multipart::FilePart.new(path, "application/x-asciicast")
136
+ }
137
+ @connection.post("/api/v1/agents/#{agent_id}/recording", payload)
138
+ rescue => e
139
+ Superkick.logger.warn("session_recorder") { "Recording upload failed for #{agent_id}: #{e.message}" }
140
+ nil
141
+ end
142
+
143
+ private
144
+
145
+ def build_connection
146
+ Faraday.new(url: @server_url) do |f|
147
+ f.request :authorization, "Bearer", @api_key
148
+ f.request :multipart
149
+ f.adapter Faraday.default_adapter
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Setup — generates a well-documented config.yml from user selections.
5
+ #
6
+ # Encapsulates config generation logic separate from the Thor command
7
+ # so it can be tested independently.
8
+ class Setup
9
+ HEADER = <<~YAML
10
+ # Superkick configuration
11
+ # Generated by `superkick setup` on %<date>s
12
+ # Docs: https://github.com/admtnnr/superkick
13
+ YAML
14
+
15
+ SETTINGS_SECTION = <<~YAML
16
+ # ── Settings ─────────────────────────────────────────────
17
+ # Uncomment to customize. Defaults are shown.
18
+ # superkick:
19
+ # poll_interval: 30 # seconds between monitor ticks
20
+ # idle_threshold: 5.0 # seconds CLI must be idle before injection
21
+ # log_level: info # debug, info, warn, error
22
+ YAML
23
+
24
+ def initialize(base_dir: nil)
25
+ @base_dir = base_dir || Superkick.config.base_dir
26
+ end
27
+
28
+ # Returns Array of { name:, cli_command:, installed: } for each registered driver.
29
+ def detect_drivers
30
+ Driver.registered.map do |name, klass|
31
+ instance = klass.new
32
+ cli_command = instance.cli_command
33
+ installed = command_installed?(cli_command)
34
+ {name:, cli_command:, installed:}
35
+ end
36
+ end
37
+
38
+ # Returns Array of { type:, label:, description: } for each monitor with setup_label.
39
+ def available_monitors
40
+ collect_available(Monitor.registered)
41
+ end
42
+
43
+ # Returns Array of { type:, label:, description: } for each spawner with setup_label.
44
+ def available_spawners
45
+ collect_available(Spawner.registered)
46
+ end
47
+
48
+ # Returns Array of { type:, label:, description: } for each notifier with setup_label.
49
+ def available_notifiers
50
+ collect_available(Notifier.registered)
51
+ end
52
+
53
+ # Returns Array of { type:, label:, description: } for each repository source with setup_label.
54
+ def available_repository_sources
55
+ collect_available(RepositorySource.registered)
56
+ end
57
+
58
+ # Assembles the final config.yml string from selected components.
59
+ def generate_config(driver:, monitors: [], spawners: [], notifiers: [], repository_sources: [])
60
+ parts = []
61
+ parts << format(HEADER, date: Date.today.iso8601)
62
+ parts << "driver: #{driver}\n"
63
+
64
+ append_monitors_section(parts, monitors)
65
+ append_spawners_section(parts, spawners)
66
+ append_notifications_section(parts, notifiers)
67
+ append_repositories_section(parts, repository_sources)
68
+ parts << SETTINGS_SECTION
69
+
70
+ parts.join("\n")
71
+ end
72
+
73
+ private
74
+
75
+ def collect_available(registry)
76
+ registry.filter_map do |type, klass|
77
+ label = klass.setup_label
78
+ next unless label
79
+
80
+ description = klass.respond_to?(:description) ? klass.description : nil
81
+ {type:, label:, description:}
82
+ end
83
+ end
84
+
85
+ def command_installed?(command)
86
+ system("which", command, out: File::NULL, err: File::NULL)
87
+ end
88
+
89
+ def append_monitors_section(parts, monitor_types)
90
+ return if monitor_types.empty?
91
+
92
+ snippets = monitor_types.filter_map { lookup_setup_config(Monitor, it) }
93
+ return if snippets.empty?
94
+
95
+ parts << <<~YAML
96
+ # ── Monitors ─────────────────────────────────────────────
97
+ # Monitors watch external services and inject context into your AI CLI.
98
+ # Config values like repo and branch are auto-detected at runtime.
99
+ monitors:
100
+ YAML
101
+ snippets.each { parts << indent(it, 2) }
102
+ end
103
+
104
+ def append_spawners_section(parts, spawner_types)
105
+ return if spawner_types.empty?
106
+
107
+ snippets = spawner_types.filter_map { lookup_setup_config(Spawner, it) }
108
+ return if snippets.empty?
109
+
110
+ parts << <<~YAML
111
+ # ── Spawners ─────────────────────────────────────────────
112
+ # Spawners watch external services and spawn new agents automatically.
113
+ spawners:
114
+ YAML
115
+ snippets.each { parts << indent(it, 2) }
116
+ end
117
+
118
+ def append_notifications_section(parts, notifier_types)
119
+ return if notifier_types.empty?
120
+
121
+ snippets = notifier_types.filter_map { lookup_setup_config(Notifier, it) }
122
+ return if snippets.empty?
123
+
124
+ parts << <<~YAML
125
+ # ── Notifications ────────────────────────────────────────
126
+ # Notifications fire after injections and agent lifecycle events.
127
+ notifications:
128
+ YAML
129
+ snippets.each { parts << indent(it, 2) }
130
+ end
131
+
132
+ def append_repositories_section(parts, source_types)
133
+ return if source_types.empty?
134
+
135
+ snippets = source_types.filter_map { lookup_setup_config(RepositorySource, it) }
136
+ return if snippets.empty?
137
+
138
+ parts << <<~YAML
139
+ # ── Repositories ─────────────────────────────────────────
140
+ # Repository sources for team planning and spawned agent workspaces.
141
+ repositories:
142
+ YAML
143
+ snippets.each { parts << indent(it, 2) }
144
+ end
145
+
146
+ def lookup_setup_config(registry_class, type)
147
+ klass = registry_class.lookup(type)
148
+ klass.setup_config
149
+ rescue ArgumentError
150
+ nil
151
+ end
152
+
153
+ def indent(text, spaces)
154
+ prefix = " " * spaces
155
+ text.lines.map { |line|
156
+ line.strip.empty? ? "\n" : "#{prefix}#{line}"
157
+ }.join
158
+ end
159
+ end
160
+ end