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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Integrations
5
+ module Slack
6
+ # Liquid Drop for a Slack message. Wraps the symbol-keyed hash built
7
+ # by Slack::Spawner so templates can use {{ message.text }},
8
+ # {{ message.sender.tag }}, {{ message.channel.ref }}, etc.
9
+ # Embeds UserDrop and ChannelDrop as nested drops for composition.
10
+ # Survives workflow context serialization via drop_type.
11
+ class MessageDrop < Superkick::Drop
12
+ def self.drop_type = "slack_message"
13
+
14
+ def text = @data[:text]
15
+
16
+ def sender = @data[:sender]
17
+
18
+ def channel = @data[:channel]
19
+
20
+ def message_ts = @data[:message_ts]
21
+
22
+ def thread_ts = @data[:thread_ts]
23
+
24
+ # Formatted reference: "@user_name in #channel"
25
+ def ref
26
+ sender_name = sender&.name || "unknown"
27
+ channel_ref = channel&.ref || "#unknown"
28
+ "@#{sender_name} in #{channel_ref}"
29
+ end
30
+ end
31
+
32
+ # Liquid Drop for a Slack user. Used in thread monitor events
33
+ # so templates can use {{ sender.name }}, {{ sender.tag }}.
34
+ class UserDrop < Superkick::Drop
35
+ def self.drop_type = "slack_user"
36
+
37
+ def id = @data[:id]
38
+
39
+ def name = @data[:name]
40
+
41
+ # Formatted tag: "Display Name (<@U123>)"
42
+ def tag
43
+ display = name || id || "unknown"
44
+ user_id = id
45
+ user_id ? "#{display} (<@#{user_id}>)" : display
46
+ end
47
+ end
48
+
49
+ # Liquid Drop for a Slack channel. Used in spawner and thread monitor
50
+ # events so templates can use {{ channel.name }}, {{ channel.ref }}.
51
+ class ChannelDrop < Superkick::Drop
52
+ def self.drop_type = "slack_channel"
53
+
54
+ def id = @data[:id]
55
+
56
+ def name = @data[:name]
57
+
58
+ # Formatted reference: "#channel-name" or the raw ID if no name.
59
+ def ref
60
+ channel_name = name || id
61
+ "##{channel_name}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ Superkick::Drop.register(Superkick::Integrations::Slack::MessageDrop)
69
+ Superkick::Drop.register(Superkick::Integrations::Slack::UserDrop)
70
+ Superkick::Drop.register(Superkick::Integrations::Slack::ChannelDrop)
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Integrations
5
+ module Slack
6
+ # Posts a message to Slack when an injection or lifecycle event occurs.
7
+ #
8
+ # Supports two authentication modes:
9
+ #
10
+ # 1. Incoming Webhook — simplest setup, posts to a single channel:
11
+ # notifications:
12
+ # - type: slack
13
+ # webhook_url: <%= env("SLACK_WEBHOOK_URL") %>
14
+ #
15
+ # 2. Web API (chat.postMessage) — can target any channel:
16
+ # notifications:
17
+ # - type: slack
18
+ # token: <%= env("SLACK_BOT_TOKEN") %>
19
+ # channel: "#superkick-notifications"
20
+ #
21
+ # Messages use Block Kit for rich formatting with a plain-text
22
+ # fallback in the top-level `text` field.
23
+ #
24
+ # Template support:
25
+ # Block Kit structure is defined via Liquid templates with custom
26
+ # block tags and bodyless tags covering all Block Kit block types.
27
+ #
28
+ # Block tags: {% header %}, {% section %}, {% context %}, {% image %},
29
+ # {% button %}, {% fields %}, {% rich_text %}, {% video %}, {% input %}
30
+ #
31
+ # Bodyless tags: {% divider %}, {% file %}
32
+ #
33
+ # Consecutive {% button %} tags auto-group into one actions block.
34
+ #
35
+ # Templates also have access to the `event_emoji`, `format_title`,
36
+ # and `join_present` filters for event type formatting.
37
+ #
38
+ # Templates are resolved from:
39
+ # 1. ~/.superkick/templates/notifications/slack/<event_type>.liquid
40
+ # 2. <bundled>/templates/<event_type>.liquid
41
+ # 3. ~/.superkick/templates/notifications/slack/default.liquid
42
+ # 4. <bundled>/templates/default.liquid
43
+ #
44
+ # Thread correlation (Web API only):
45
+ # When using the Web API, the notifier is stateful and tracks
46
+ # `thread_ts` per agent. The first message for an agent creates
47
+ # a new thread; subsequent events reply to that thread. This keeps
48
+ # the channel clean when many agents are active. Webhook mode
49
+ # cannot thread (Slack webhooks don't return a `ts`), so messages
50
+ # are standalone.
51
+ class Notifier < Superkick::Notifier
52
+ def self.type = :slack
53
+
54
+ def self.templates_dir = File.expand_path("templates", __dir__)
55
+
56
+ def self.setup_label = "Slack"
57
+
58
+ def self.setup_config
59
+ <<~YAML
60
+ # Slack — choose one authentication mode:
61
+
62
+ # Option 1: Incoming Webhook (single channel, no token needed)
63
+ - type: slack
64
+ webhook_url: <%= env("SLACK_WEBHOOK_URL") %>
65
+
66
+ # Option 2: Web API (any channel, supports threading)
67
+ # - type: slack
68
+ # token: <%= env("SLACK_BOT_TOKEN") %>
69
+ # channel: "#superkick-notifications"
70
+ # # events: # restrict which events fire
71
+ # # - agent_completed
72
+ # # - agent_failed
73
+ # # - agent_blocked
74
+ YAML
75
+ end
76
+
77
+ WEBHOOK_TIMEOUT = 10
78
+ API_URL = "https://slack.com"
79
+
80
+ EMOJI_MAP = {
81
+ "agent_spawned" => ":rocket:",
82
+ "agent_completed" => ":white_check_mark:",
83
+ "agent_failed" => ":x:",
84
+ "agent_timed_out" => ":hourglass:",
85
+ "agent_blocked" => ":warning:",
86
+ "agent_stalled" => ":zzz:",
87
+ "agent_terminated" => ":stop_sign:",
88
+ "agent_claimed" => ":raised_hand:",
89
+ "agent_unclaimed" => ":wave:",
90
+ "agent_pending_approval" => ":eyes:",
91
+ "workflow_triggered" => ":gear:",
92
+ "workflow_iterations_exceeded" => ":no_entry:",
93
+ "budget_warning" => ":money_with_wings:",
94
+ "budget_exceeded" => ":moneybag:",
95
+ "team_created" => ":busts_in_silhouette:",
96
+ "team_completed" => ":trophy:",
97
+ "team_failed" => ":boom:",
98
+ "team_timed_out" => ":hourglass_flowing_sand:",
99
+ "worker_spawned" => ":construction_worker:",
100
+ "teammate_message" => ":speech_balloon:",
101
+ "teammate_blocker" => ":octagonal_sign:",
102
+ "attach_promoted" => ":arrow_up:",
103
+ "attach_demoted" => ":arrow_down:",
104
+ "attach_idle_timeout" => ":zzz:",
105
+ "attach_force_takeover" => ":rotating_light:",
106
+ "agent_update" => ":memo:",
107
+ "artifact_published" => ":package:"
108
+ }.freeze
109
+
110
+ # Keyword-to-emoji mapping for fuzzy matching on unknown event types.
111
+ # Checked in order — first keyword found in the event type string wins.
112
+ KEYWORD_EMOJI = [
113
+ ["completed", ":white_check_mark:"],
114
+ ["succeeded", ":white_check_mark:"],
115
+ ["passed", ":white_check_mark:"],
116
+ ["failed", ":x:"],
117
+ ["errored", ":x:"],
118
+ ["error", ":x:"],
119
+ ["timed_out", ":hourglass:"],
120
+ ["timeout", ":hourglass:"],
121
+ ["warning", ":warning:"],
122
+ ["blocked", ":warning:"],
123
+ ["stalled", ":zzz:"],
124
+ ["spawned", ":rocket:"],
125
+ ["created", ":busts_in_silhouette:"],
126
+ ["started", ":rocket:"],
127
+ ["terminated", ":stop_sign:"],
128
+ ["stopped", ":stop_sign:"],
129
+ ["exceeded", ":no_entry:"],
130
+ ["triggered", ":gear:"],
131
+ ["message", ":speech_balloon:"],
132
+ ["approved", ":white_check_mark:"],
133
+ ["rejected", ":no_entry:"],
134
+ ["deployed", ":rocket:"],
135
+ ["merged", ":white_check_mark:"]
136
+ ].freeze
137
+
138
+ def self.event_emoji(event_type)
139
+ type_str = event_type.to_s
140
+ return EMOJI_MAP[type_str] if EMOJI_MAP.key?(type_str)
141
+
142
+ KEYWORD_EMOJI.each do |keyword, emoji|
143
+ return emoji if type_str.include?(keyword)
144
+ end
145
+
146
+ ":bell:"
147
+ end
148
+
149
+ liquid do
150
+ context do
151
+ attribute :blocks, default: -> { [] }
152
+ end
153
+
154
+ filter :event_emoji do |event_type|
155
+ Superkick::Integrations::Slack::Notifier.event_emoji(event_type)
156
+ end
157
+
158
+ filter :format_title do |event_type|
159
+ event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
160
+ end
161
+
162
+ filter :join_present do |input, delimiter = " | "|
163
+ input.to_s.split("\n").map(&:strip).reject(&:empty?).join(delimiter)
164
+ end
165
+
166
+ block :header do |ctx, text, _attrs|
167
+ ctx.blocks << {type: "header", text: {type: "plain_text", text:, emoji: true}}
168
+ end
169
+
170
+ block :section do |ctx, text, _attrs|
171
+ ctx.blocks << {type: "section", text: {type: "mrkdwn", text:}}
172
+ end
173
+
174
+ block :context do |ctx, text, _attrs|
175
+ ctx.blocks << {type: "context", elements: [{type: "mrkdwn", text:}]}
176
+ end
177
+
178
+ tag :divider do |ctx, _attrs|
179
+ ctx.blocks << {type: "divider"}
180
+ end
181
+
182
+ block :image do |ctx, text, attrs|
183
+ block = {
184
+ type: "image",
185
+ image_url: attrs["url"],
186
+ alt_text: attrs["alt_text"] || ""
187
+ }
188
+ block[:title] = {type: "plain_text", text:, emoji: true} unless text.empty?
189
+ ctx.blocks << block
190
+ end
191
+
192
+ block :button do |ctx, text, attrs|
193
+ element = {
194
+ type: "button",
195
+ text: {type: "plain_text", text:, emoji: true}
196
+ }
197
+ element[:url] = attrs["url"] if attrs["url"]
198
+ element[:action_id] = attrs["action_id"] if attrs["action_id"]
199
+ element[:value] = attrs["value"] if attrs["value"]
200
+ element[:style] = attrs["style"] if attrs["style"]
201
+
202
+ # Auto-group: consecutive buttons share one actions block
203
+ if ctx.blocks.last&.dig(:type) == "actions"
204
+ ctx.blocks.last[:elements] << element
205
+ else
206
+ ctx.blocks << {type: "actions", elements: [element]}
207
+ end
208
+ end
209
+
210
+ block :fields do |ctx, text, _attrs|
211
+ field_texts = text.split("\n").map(&:strip).reject(&:empty?)
212
+ fields = field_texts.map { {type: "mrkdwn", text: it} }
213
+ ctx.blocks << {type: "section", fields:}
214
+ end
215
+
216
+ block :rich_text do |ctx, text, _attrs|
217
+ ctx.blocks << {
218
+ type: "rich_text",
219
+ elements: [{
220
+ type: "rich_text_section",
221
+ elements: [{type: "text", text:}]
222
+ }]
223
+ }
224
+ end
225
+
226
+ block :video do |ctx, text, attrs|
227
+ block = {
228
+ type: "video",
229
+ title: {type: "plain_text", text:, emoji: true},
230
+ video_url: attrs["url"],
231
+ thumbnail_url: attrs["thumbnail_url"] || "",
232
+ alt_text: attrs["alt_text"] || ""
233
+ }
234
+ block[:description] = {type: "plain_text", text: attrs["description"]} if attrs["description"]
235
+ ctx.blocks << block
236
+ end
237
+
238
+ tag :file do |ctx, attrs|
239
+ ctx.blocks << {
240
+ type: "file",
241
+ external_id: attrs["external_id"],
242
+ source: attrs["source"] || "remote"
243
+ }
244
+ end
245
+
246
+ block :input do |ctx, text, attrs|
247
+ element = {type: attrs["type"] || "plain_text_input"}
248
+ element[:action_id] = attrs["action_id"] if attrs["action_id"]
249
+ if attrs["placeholder"]
250
+ element[:placeholder] = {type: "plain_text", text: attrs["placeholder"]}
251
+ end
252
+
253
+ block = {
254
+ type: "input",
255
+ label: {type: "plain_text", text:, emoji: true},
256
+ element:
257
+ }
258
+ block[:dispatch_action] = true if attrs["dispatch_action"] == "true"
259
+ ctx.blocks << block
260
+ end
261
+ end
262
+
263
+ def initialize(webhook_url: nil, token: nil, channel: nil, thread_ts: nil, connection: nil, **opts)
264
+ super(**opts)
265
+ @webhook_url = webhook_url
266
+ @token = token
267
+ @channel = channel
268
+ @initial_thread_ts = thread_ts
269
+ @connection = connection
270
+ end
271
+
272
+ # Stateful when using the Web API — tracks thread_ts per agent.
273
+ # Webhook mode cannot thread (no ts in response), so it's effectively stateless.
274
+ def stateful? = !!(@token && @channel)
275
+
276
+ def agent_finished(agent_id:)
277
+ @state_store.delete(:slack, agent_id.to_s)
278
+ end
279
+
280
+ def notify(payload)
281
+ body = build_body(payload)
282
+
283
+ if @webhook_url
284
+ post_webhook(body)
285
+ elsif @token && @channel
286
+ thread_key = payload.dig(:context, :team)&.id || payload[:agent_id]
287
+ seed_thread_ts = payload.dig(:context, :slack_thread_ts)
288
+ post_api(body, agent_id: payload[:agent_id], thread_key:, seed_thread_ts:)
289
+ else
290
+ Superkick.logger.warn("notifier:slack") { "No webhook_url or token+channel configured" }
291
+ end
292
+ rescue => e
293
+ Superkick.logger.warn("notifier:slack") { "Failed: #{e.message}" }
294
+ end
295
+
296
+ private
297
+
298
+ def post_webhook(body)
299
+ uri = URI.parse(@webhook_url)
300
+ conn = @connection || build_connection(url: "#{uri.scheme}://#{uri.host}")
301
+ response = conn.post(uri.request_uri) do |req|
302
+ req.body = body
303
+ end
304
+
305
+ unless (200..299).cover?(response.status)
306
+ Superkick.logger.warn("notifier:slack") { "Webhook returned HTTP #{response.status}" }
307
+ end
308
+ end
309
+
310
+ def post_api(body, agent_id: nil, thread_key: nil, seed_thread_ts: nil)
311
+ body[:channel] = @channel
312
+
313
+ key = thread_key || agent_id
314
+ thread_ts = key && @state_store.get(:slack, key.to_s)&.dig(:thread_ts)
315
+
316
+ # Seed from constructor (per-agent notifier) or spawn context
317
+ seed = seed_thread_ts || @initial_thread_ts
318
+ if !thread_ts && seed && key
319
+ thread_ts = seed
320
+ @state_store.put(:slack, key.to_s, {thread_ts:})
321
+ end
322
+
323
+ body[:thread_ts] = thread_ts if thread_ts
324
+
325
+ conn = @connection || build_connection(url: API_URL, token: @token)
326
+ response = conn.post("/api/chat.postMessage") do |req|
327
+ req.body = body
328
+ end
329
+
330
+ if response.status == 200
331
+ data = response.body
332
+ if data[:ok]
333
+ # Capture thread_ts from the first message for this agent/team
334
+ if !thread_ts && key && data[:ts]
335
+ existing = @state_store.get(:slack, key.to_s)
336
+ @state_store.put(:slack, key.to_s, {thread_ts: data[:ts]}) unless existing
337
+ end
338
+ else
339
+ Superkick.logger.warn("notifier:slack") { "Slack API error: #{data[:error]}" }
340
+ end
341
+ else
342
+ Superkick.logger.warn("notifier:slack") { "Slack API returned HTTP #{response.status}" }
343
+ end
344
+ end
345
+
346
+ def build_connection(url:, token: nil)
347
+ Faraday.new(url:) do |f|
348
+ f.request :json
349
+ f.request :authorization, "Bearer", token if token
350
+ f.response :json, parser_options: {symbolize_names: true}
351
+ f.options.timeout = WEBHOOK_TIMEOUT
352
+ f.options.open_timeout = WEBHOOK_TIMEOUT
353
+ end
354
+ end
355
+
356
+ def build_body(payload)
357
+ template_result = render_notification(payload)
358
+
359
+ if template_result && !template_result[:structured].blocks.empty?
360
+ {
361
+ text: template_result[:text],
362
+ blocks: template_result[:structured].blocks
363
+ }
364
+ else
365
+ build_body_fallback(payload)
366
+ end
367
+ end
368
+
369
+ def build_body_fallback(payload)
370
+ event_type = payload[:event_type]
371
+ emoji = self.class.event_emoji(event_type)
372
+ title = format_title(event_type)
373
+ message = payload[:message]
374
+
375
+ {
376
+ text: message,
377
+ blocks: build_blocks_fallback(emoji:, title:, payload:)
378
+ }
379
+ end
380
+
381
+ def build_blocks_fallback(emoji:, title:, payload:)
382
+ blocks = [
383
+ {
384
+ type: "header",
385
+ text: {type: "plain_text", text: "#{emoji} #{title}", emoji: true}
386
+ },
387
+ {
388
+ type: "section",
389
+ text: {type: "mrkdwn", text: payload[:message]}
390
+ }
391
+ ]
392
+
393
+ context_parts = []
394
+ context_parts << "*Agent:* `#{payload[:agent_id]}`" unless payload[:agent_id].to_s.empty?
395
+
396
+ monitor = payload[:monitor]
397
+ if monitor
398
+ context_parts << "*Monitor:* #{monitor.name}" unless monitor.name.to_s.empty?
399
+ context_parts << "*Type:* #{monitor.type}" unless monitor.type.to_s.empty?
400
+ end
401
+
402
+ ctx = payload[:context] || {}
403
+ team = ctx[:team]
404
+ context_parts << "*Team:* `#{team.id}`" if team&.id
405
+ agent = ctx[:agent]
406
+ context_parts << "*Role:* #{agent.role}" if agent&.role
407
+ spawner = ctx[:spawner]
408
+ context_parts << "*Spawner:* #{spawner.name}" if spawner
409
+
410
+ unless context_parts.empty?
411
+ blocks << {
412
+ type: "context",
413
+ elements: [{type: "mrkdwn", text: context_parts.join(" | ")}]
414
+ }
415
+ end
416
+
417
+ blocks
418
+ end
419
+
420
+ def format_title(event_type)
421
+ event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
422
+ end
423
+ end
424
+ end
425
+ end
426
+ end