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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Superkick
6
+ module Integrations
7
+ module Slack
8
+ # Watches a Slack channel for new messages and spawns agents.
9
+ #
10
+ # Polls `conversations.history` for top-level messages. Each new
11
+ # message becomes a spawn event. When `monitor_thread: true` (the
12
+ # default), the spawned agent automatically gets a `slack_thread`
13
+ # monitor that feeds user replies back as injections.
14
+ #
15
+ # Config keys:
16
+ # channel (required) — Slack channel ID (e.g. "C0123456789")
17
+ # token (optional) — Bot token, falls back to SLACK_BOT_TOKEN
18
+ # channel_name (optional) — Human-readable name for templates
19
+ # filter_pattern (optional) — Regex; only matching messages trigger spawns
20
+ # ignore_bots (optional, default true) — skip bot messages
21
+ # ignore_threads (optional, default true) — skip threaded replies
22
+ # monitor_thread (optional, default true) — auto-attach slack_thread monitor
23
+ class Spawner < Superkick::Spawner
24
+ API_URL = "https://slack.com"
25
+
26
+ attr_reader :conn, :seen_ts, :bot_user_id, :user_cache
27
+
28
+ def self.type = :slack
29
+
30
+ def self.description
31
+ "Watches a Slack channel for new messages and spawns AI coding " \
32
+ "agents to handle them. Supports thread monitoring so users " \
33
+ "can interact with the agent via Slack replies."
34
+ end
35
+
36
+ def self.required_config = %i[channel]
37
+
38
+ def self.spawn_templates_dir
39
+ File.join(__dir__, "templates", "spawn")
40
+ end
41
+
42
+ def self.agent_id(event)
43
+ "slack-message-#{event[:channel_id]}-#{event[:message_ts].tr(".", "-")}"
44
+ end
45
+
46
+ def self.setup_label = "Slack"
47
+
48
+ def self.setup_config
49
+ <<~YAML
50
+ slack:
51
+ type: slack
52
+ channel: C0123456789 # Slack channel ID
53
+ token: <%= env("SLACK_BOT_TOKEN") %>
54
+ # channel_name: general # for display only
55
+ # ignore_bots: true # ignore bot messages (default)
56
+ # ignore_threads: true # only top-level messages (default)
57
+ # monitor_thread: true # auto-attach thread monitor (default)
58
+ # filter_pattern: "^!ask " # regex to filter messages
59
+ # max_duration: 3600
60
+ YAML
61
+ end
62
+
63
+ def initialize(name:, config:, handler:, connection: nil)
64
+ super(name:, config:, handler:)
65
+ @conn = connection
66
+ end
67
+
68
+ def on_start
69
+ @conn ||= build_connection
70
+ @bot_user_id = fetch_bot_user_id
71
+ @oldest = Time.now.to_f.to_s
72
+ @seen_ts = Set.new
73
+ @user_cache = {}
74
+ end
75
+
76
+ def tick
77
+ messages = fetch_messages
78
+ messages.each do |msg|
79
+ event = build_event(msg)
80
+ attach_spawn_monitors(event, msg)
81
+ dispatch(event)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def fetch_messages
88
+ resp = @conn.get("/api/conversations.history") do |req|
89
+ req.params[:channel] = self[:channel]
90
+ req.params[:oldest] = @oldest
91
+ req.params[:limit] = 100
92
+ end
93
+
94
+ handle_response!(resp)
95
+ data = resp.body
96
+ return [] unless data[:ok]
97
+
98
+ messages = data[:messages] || []
99
+
100
+ # Filter bot messages
101
+ if self[:ignore_bots] != false
102
+ messages = messages.reject { it[:subtype] == "bot_message" || it[:bot_id] }
103
+ messages = messages.reject { it[:user] == @bot_user_id } if @bot_user_id
104
+ end
105
+
106
+ # Filter threaded replies (only top-level messages)
107
+ if self[:ignore_threads] != false
108
+ messages = messages.reject { it[:thread_ts] && it[:thread_ts] != it[:ts] }
109
+ end
110
+
111
+ # Apply filter_pattern
112
+ if self[:filter_pattern]
113
+ pattern = Regexp.new(self[:filter_pattern])
114
+ messages = messages.select { pattern.match?(it[:text].to_s) }
115
+ end
116
+
117
+ # Within-session dedup
118
+ new_messages = messages.reject { @seen_ts.include?(it[:ts]) }
119
+ new_messages.each { @seen_ts.add(it[:ts]) }
120
+
121
+ # Advance watermark
122
+ if messages.any?
123
+ latest = messages.map { it[:ts] }.max
124
+ @oldest = latest if latest && latest > @oldest
125
+ end
126
+
127
+ Superkick.logger.info(log_tag) { "Found #{messages.size} messages, #{new_messages.size} new" }
128
+ new_messages
129
+ end
130
+
131
+ def build_event(msg)
132
+ user_name = resolve_user_name(msg[:user])
133
+
134
+ sender = UserDrop.new(
135
+ id: msg[:user],
136
+ name: user_name
137
+ )
138
+
139
+ channel = ChannelDrop.new(
140
+ id: self[:channel],
141
+ name: self[:channel_name]
142
+ )
143
+
144
+ message = MessageDrop.new(
145
+ text: msg[:text].to_s,
146
+ sender:,
147
+ channel:,
148
+ message_ts: msg[:ts],
149
+ thread_ts: msg[:ts]
150
+ )
151
+
152
+ {
153
+ event_type: :slack_message,
154
+ channel_id: self[:channel],
155
+ channel_name: self[:channel_name] || self[:channel],
156
+ message_ts: msg[:ts],
157
+ slack_thread_ts: msg[:ts],
158
+ user: msg[:user],
159
+ user_name:,
160
+ text: msg[:text].to_s,
161
+ message:,
162
+ sender:,
163
+ channel:
164
+ }
165
+ end
166
+
167
+ def attach_spawn_monitors(event, _msg)
168
+ return unless self[:monitor_thread] != false
169
+
170
+ token = self[:token] || ENV["SLACK_BOT_TOKEN"]
171
+ event[:_spawn_monitors] = {
172
+ slack_thread: {
173
+ type: :slack_thread,
174
+ channel_id: self[:channel],
175
+ thread_ts: event[:message_ts],
176
+ token:
177
+ }
178
+ }
179
+
180
+ # Auto-attach a per-agent Slack notifier so lifecycle updates
181
+ # and agent events flow back to the originating Slack thread.
182
+ event[:_spawn_notifiers] = {
183
+ slack_thread: {
184
+ type: :slack,
185
+ token:,
186
+ channel: self[:channel],
187
+ thread_ts: event[:message_ts]
188
+ }
189
+ }
190
+ end
191
+
192
+ def resolve_user_name(user_id)
193
+ return nil unless user_id
194
+ return @user_cache[user_id] if @user_cache.key?(user_id)
195
+
196
+ resp = @conn.get("/api/users.info") do |req|
197
+ req.params[:user] = user_id
198
+ end
199
+
200
+ if resp.status == 200 && resp.body[:ok]
201
+ name = resp.body.dig(:user, :profile, :display_name)
202
+ name = resp.body.dig(:user, :real_name) if name.to_s.empty?
203
+ name = resp.body.dig(:user, :name) if name.to_s.empty?
204
+ @user_cache[user_id] = name || user_id
205
+ else
206
+ @user_cache[user_id] = user_id
207
+ end
208
+
209
+ @user_cache[user_id]
210
+ rescue => e
211
+ Superkick.logger.debug(log_tag) { "Could not resolve user #{user_id}: #{e.message}" }
212
+ @user_cache[user_id] = user_id
213
+ end
214
+
215
+ def fetch_bot_user_id
216
+ resp = @conn.get("/api/auth.test")
217
+ return nil unless resp.status == 200 && resp.body[:ok]
218
+
219
+ resp.body[:user_id]
220
+ rescue => e
221
+ Superkick.logger.warn(log_tag) { "Could not fetch bot identity: #{e.message}" }
222
+ nil
223
+ end
224
+
225
+ def handle_response!(resp)
226
+ case resp.status
227
+ when 200..299
228
+ # Slack API returns 200 for most errors; check :ok field in caller
229
+ nil
230
+ when 401, 403
231
+ raise FatalError, "Slack auth failed (HTTP #{resp.status})"
232
+ when 429
233
+ raise RateLimited, "Slack rate limited"
234
+ end
235
+ end
236
+
237
+ def build_connection
238
+ token = self[:token] || ENV["SLACK_BOT_TOKEN"]
239
+
240
+ Faraday.new(url: API_URL) do |f|
241
+ f.request :authorization, "Bearer", token if token
242
+ f.request :json
243
+ f.response :json, parser_options: {symbolize_names: true}
244
+ f.options.timeout = 10
245
+ f.options.open_timeout = 10
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,17 @@
1
+ {% header %}{{ event_type | event_emoji }} {{ event_type | format_title }}{% endheader %}
2
+ {% section %}{{ message }}{% endsection %}
3
+ {% capture meta %}
4
+ {% if agent_id %}*Agent:* `{{ agent_id }}`
5
+ {% endif %}
6
+ {% if monitor %}*Monitor:* {{ monitor.name }}
7
+ *Type:* {{ monitor.type }}
8
+ {% endif %}
9
+ {% if team %}*Team:* `{{ team.id }}`
10
+ {% endif %}
11
+ {% if agent.role %}*Role:* {{ agent.role }}
12
+ {% endif %}
13
+ {% if spawner %}*Spawner:* {{ spawner.name }}
14
+ {% endif %}
15
+ {% endcapture %}
16
+ {% if meta != blank %}{% context %}{{ meta | join_present: " | " }}{% endcontext %}{% endif %}
17
+ {{ message }}
@@ -0,0 +1,3 @@
1
+ SUPERKICK [{{ "now" | time }}]: Slack reply from {{ user_name }}
2
+
3
+ {{ text }}
@@ -0,0 +1,10 @@
1
+ SUPERKICK [{{ "now" | time }}]: Slack message from {{ user_name }} in {{ channel_name }}
2
+
3
+ ## Message
4
+
5
+ {{ text }}
6
+
7
+ Please read the message carefully and work on the request. If the user
8
+ provided specific instructions, follow them. If you need clarification,
9
+ use superkick_post_update to communicate your progress or questions.
10
+ When you are done, use superkick_signal_goal to signal completion.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Superkick
6
+ module Integrations
7
+ module Slack
8
+ # Polls a Slack thread for new replies and injects them into the agent.
9
+ #
10
+ # Typically auto-attached by the Slack spawner via `_spawn_monitors`
11
+ # when `monitor_thread: true`. Each new reply from a human user
12
+ # becomes a high-priority injection event.
13
+ #
14
+ # Config keys (injected at spawn time):
15
+ # channel_id (required) — Slack channel ID
16
+ # thread_ts (required) — Root message timestamp
17
+ # token (optional) — Bot token, falls back to SLACK_BOT_TOKEN
18
+ class ThreadMonitor < Superkick::Monitor
19
+ API_URL = "https://slack.com"
20
+
21
+ attr_reader :conn, :seen_ts, :bot_user_id
22
+
23
+ def self.type = :slack_thread
24
+
25
+ def self.description
26
+ "Polls a Slack thread for new replies and injects them as " \
27
+ "high-priority prompts into the agent."
28
+ end
29
+
30
+ def self.required_config = %i[channel_id thread_ts]
31
+
32
+ def self.templates_dir
33
+ File.join(__dir__, "templates")
34
+ end
35
+
36
+ def initialize(name:, config:, handler:, agent: nil, server_context: {}, connection: nil)
37
+ super(name:, config:, handler:, agent:, server_context:)
38
+ @conn = connection
39
+ end
40
+
41
+ def on_start
42
+ @conn ||= build_connection
43
+ @bot_user_id = fetch_bot_user_id
44
+ @oldest = self[:thread_ts]
45
+ @seen_ts = Set.new([self[:thread_ts]])
46
+ @user_cache = {}
47
+ end
48
+
49
+ def tick
50
+ replies = fetch_replies
51
+ replies.each do |reply|
52
+ user_name = resolve_user_name(reply[:user])
53
+ sender = Slack::UserDrop.new(id: reply[:user], name: user_name)
54
+ channel = Slack::ChannelDrop.new(id: self[:channel_id])
55
+
56
+ dispatch(
57
+ event_type: :slack_reply,
58
+ user: reply[:user],
59
+ user_name:,
60
+ text: reply[:text].to_s,
61
+ message_ts: reply[:ts],
62
+ sender:,
63
+ channel:,
64
+ injection_priority: :high,
65
+ injection_ttl: 900
66
+ )
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def fetch_replies
73
+ resp = @conn.get("/api/conversations.replies") do |req|
74
+ req.params[:channel] = self[:channel_id]
75
+ req.params[:ts] = self[:thread_ts]
76
+ req.params[:oldest] = @oldest
77
+ req.params[:limit] = 100
78
+ end
79
+
80
+ handle_response!(resp)
81
+ data = resp.body
82
+ return [] unless data[:ok]
83
+
84
+ messages = data[:messages] || []
85
+
86
+ # Skip bot's own messages
87
+ if @bot_user_id
88
+ messages = messages.reject { it[:user] == @bot_user_id }
89
+ end
90
+ messages = messages.reject { it[:subtype] == "bot_message" || it[:bot_id] }
91
+
92
+ # Within-session dedup
93
+ new_replies = messages.reject { @seen_ts.include?(it[:ts]) }
94
+ new_replies.each { @seen_ts.add(it[:ts]) }
95
+
96
+ # Advance watermark
97
+ if messages.any?
98
+ latest = messages.map { it[:ts] }.max
99
+ @oldest = latest if latest && latest > @oldest
100
+ end
101
+
102
+ Superkick.logger.info(log_tag) { "Found #{new_replies.size} new replies" }
103
+ new_replies
104
+ end
105
+
106
+ def resolve_user_name(user_id)
107
+ return nil unless user_id
108
+ return @user_cache[user_id] if @user_cache.key?(user_id)
109
+
110
+ resp = @conn.get("/api/users.info") do |req|
111
+ req.params[:user] = user_id
112
+ end
113
+
114
+ if resp.status == 200 && resp.body[:ok]
115
+ name = resp.body.dig(:user, :profile, :display_name)
116
+ name = resp.body.dig(:user, :real_name) if name.to_s.empty?
117
+ name = resp.body.dig(:user, :name) if name.to_s.empty?
118
+ @user_cache[user_id] = name || user_id
119
+ else
120
+ @user_cache[user_id] = user_id
121
+ end
122
+
123
+ @user_cache[user_id]
124
+ rescue => e
125
+ Superkick.logger.debug(log_tag) { "Could not resolve user #{user_id}: #{e.message}" }
126
+ @user_cache[user_id] = user_id
127
+ end
128
+
129
+ def fetch_bot_user_id
130
+ resp = @conn.get("/api/auth.test")
131
+ return nil unless resp.status == 200 && resp.body[:ok]
132
+
133
+ resp.body[:user_id]
134
+ rescue => e
135
+ Superkick.logger.warn(log_tag) { "Could not fetch bot identity: #{e.message}" }
136
+ nil
137
+ end
138
+
139
+ def handle_response!(resp)
140
+ case resp.status
141
+ when 200..299 then nil
142
+ when 401, 403 then raise FatalError, "Slack auth failed (HTTP #{resp.status})"
143
+ when 429 then raise RateLimited, "Slack rate limited"
144
+ end
145
+ end
146
+
147
+ def build_connection
148
+ token = self[:token] || ENV["SLACK_BOT_TOKEN"]
149
+
150
+ Faraday.new(url: API_URL) do |f|
151
+ f.request :authorization, "Bearer", token if token
152
+ f.request :json
153
+ f.response :json, parser_options: {symbolize_names: true}
154
+ f.options.timeout = 10
155
+ f.options.open_timeout = 10
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slack/drops"
4
+ require_relative "slack/notifier"
5
+ require_relative "slack/spawner"
6
+ require_relative "slack/thread_monitor"
7
+
8
+ module Superkick
9
+ Notifier.register(Integrations::Slack::Notifier)
10
+ Spawner.register(Integrations::Slack::Spawner)
11
+ Monitor.register(Integrations::Slack::ThreadMonitor)
12
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Liquid — includable DSL for declaring Liquid customizations on monitors,
5
+ # spawners, and notifiers. Provides `liquid do ... end` class-level DSL.
6
+ #
7
+ # Usage:
8
+ #
9
+ # class MyMonitor < Monitor
10
+ # liquid do
11
+ # filter :pr_ref do |number, title = nil|
12
+ # title ? "##{number} \"#{title}\"" : "##{number}"
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # class MyNotifier < Notifier
18
+ # liquid do
19
+ # context do
20
+ # attribute :blocks, default: -> { [] }
21
+ # attribute :text
22
+ # end
23
+ #
24
+ # block :header do |ctx, content|
25
+ # ctx.blocks << { type: "header", text: content }
26
+ # end
27
+ # end
28
+ # end
29
+ module Liquid
30
+ def self.included(base)
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ module ClassMethods
35
+ def liquid(&definition)
36
+ @liquid_config = Config.new
37
+ @liquid_config.instance_eval(&definition)
38
+ @liquid_config.freeze
39
+ end
40
+
41
+ def liquid_config
42
+ @liquid_config || Config::EMPTY
43
+ end
44
+ end
45
+
46
+ # Config — captures DSL declarations (filters, context class, blocks).
47
+ # Immutable after freeze.
48
+ class Config
49
+ attr_reader :filters, :context_class, :blocks, :tags
50
+
51
+ def initialize
52
+ @filters = {}
53
+ @context_class = nil
54
+ @blocks = {}
55
+ @tags = {}
56
+ end
57
+
58
+ # Declare an inline filter. Generates a Liquid filter module behind the scenes.
59
+ def filter(name, &handler)
60
+ @filters[name] = handler
61
+ end
62
+
63
+ # Declare the context class for block tag accumulation.
64
+ # Without a block: sets a pre-existing class.
65
+ # With a block: builds an anonymous context class with attribute DSL.
66
+ def context(klass = nil, &definition)
67
+ if klass
68
+ @context_class = klass
69
+ elsif definition
70
+ @context_class = Context.build(&definition)
71
+ end
72
+ end
73
+
74
+ # Declare a Liquid block tag.
75
+ def block(name, &handler)
76
+ @blocks[name] = handler
77
+ end
78
+
79
+ # Declare a bodyless Liquid tag (no end tag required).
80
+ # Handler receives (accumulator, attributes).
81
+ def tag(name, &handler)
82
+ @tags[name] = handler
83
+ end
84
+
85
+ def freeze
86
+ @filters.freeze
87
+ @blocks.freeze
88
+ @tags.freeze
89
+ super
90
+ end
91
+
92
+ EMPTY = new.freeze
93
+ end
94
+
95
+ # Context — builder for anonymous context classes used as block tag
96
+ # accumulators. Builds a class with declared attributes and defaults.
97
+ class Context
98
+ def self.build(&definition)
99
+ builder = new
100
+ builder.instance_eval(&definition)
101
+ builder.build_class
102
+ end
103
+
104
+ def initialize
105
+ @attributes = {}
106
+ end
107
+
108
+ def attribute(name, default: nil)
109
+ @attributes[name] = default
110
+ end
111
+
112
+ def build_class
113
+ attrs = @attributes.dup
114
+ Class.new do
115
+ attrs.each_key do |name|
116
+ attr_accessor name
117
+ end
118
+
119
+ define_method(:initialize) do
120
+ attrs.each do |name, default|
121
+ value = default.respond_to?(:call) ? default.call : default
122
+ instance_variable_set(:"@#{name}", value)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end