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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Monitor — abstract base class for agent-bound event source monitors.
5
+ #
6
+ # Inherits the run loop, error handling, backoff, and config validation
7
+ # from Poller. Adds agent binding, the Probe nested class, and the
8
+ # class-level monitor registry.
9
+ #
10
+ # Subclass contract (in addition to Poller's):
11
+ # tick → called each interval; use dispatch(event) to emit
12
+ # on_start → optional hook called once before the run loop
13
+ #
14
+ # Optionally define a nested Probe class (< Monitor::Probe) for auto-detection
15
+ # of monitor config from the local environment.
16
+ #
17
+ # Subclasses register themselves:
18
+ # Superkick::Monitor.register(MyMonitor)
19
+ class Monitor < Poller
20
+ # ── Probe — abstract base for environment probes ──────────────────────
21
+ #
22
+ # Probes run server-side using an environment snapshot collected from the
23
+ # agent at registration time. The server aggregates environment_actions
24
+ # from all registered probes, sends them to the agent, and distributes
25
+ # the response to each probe's detect method.
26
+ #
27
+ # Subclass contract:
28
+ # self.type → unique Symbol (conventionally matches the Monitor type)
29
+ # self.description → human-readable summary of what the probe detects (optional)
30
+ # self.environment_actions → Array of action hashes needed by this probe
31
+ # self.detect(environment:) → {} or { monitor_name: { type: "…", config… } }
32
+ class Probe
33
+ @registry = {}
34
+
35
+ class << self
36
+ include Superkick::Registry
37
+
38
+ def register(klass)
39
+ raise ArgumentError, "#{klass} must define self.type" unless klass.respond_to?(:type)
40
+
41
+ key = klass.type.to_sym
42
+ raise ArgumentError, "Probe type :#{key} already registered" if @registry.key?(key)
43
+
44
+ @registry[key] = klass
45
+ end
46
+
47
+ def deregister(type)
48
+ @registry.delete(type.to_sym)
49
+ end
50
+
51
+ def registered
52
+ @registry.dup.freeze
53
+ end
54
+
55
+ # Replace the probe registry. Used by tests to isolate probe
56
+ # registration without disturbing globally registered probes.
57
+ def reset_registry!(registry = {})
58
+ @registry = registry
59
+ end
60
+
61
+ # Collect environment actions from all registered probes, deduplicated.
62
+ # @return [Array<Hash>] merged action list
63
+ def all_environment_actions
64
+ seen = Set.new
65
+ @registry.values.each_with_object([]) do |klass, actions|
66
+ next unless klass.respond_to?(:environment_actions)
67
+
68
+ klass.environment_actions.each do |action|
69
+ key = action_dedup_key(action)
70
+ unless seen.include?(key)
71
+ seen.add(key)
72
+ actions << action
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ # Run every registered probe against an environment snapshot.
79
+ # @param environment [Hash] environment data from the agent
80
+ # @return [Hash] merged monitor configs from all probes
81
+ def detect_all(environment:)
82
+ @registry.values.each_with_object({}) do |klass, h|
83
+ h.merge!(klass.detect(environment:) || {})
84
+ end
85
+ end
86
+
87
+ def type
88
+ raise NotImplementedError, "#{self}.type not defined"
89
+ end
90
+
91
+ # Optional human-readable description of what the probe detects.
92
+ def description
93
+ nil
94
+ end
95
+
96
+ # Environment actions this probe requires. Override in subclasses.
97
+ # @return [Array<Hash>] e.g. [{ action: :git_branch }, { action: :git_remotes }]
98
+ def environment_actions
99
+ []
100
+ end
101
+
102
+ # Subclasses must override.
103
+ # @param environment [Hash] environment data from the agent
104
+ # @return [Hash] empty or { monitor_name: config_hash }
105
+ def detect(environment:)
106
+ raise NotImplementedError, "#{self}.detect not implemented"
107
+ end
108
+
109
+ private
110
+
111
+ # Deduplication key for environment actions.
112
+ # :file_exists actions with different paths are distinct.
113
+ def action_dedup_key(action)
114
+ key = action[:action].to_sym
115
+ if key == :file_exists
116
+ paths = (action[:paths] || []).sort
117
+ [key, paths]
118
+ else
119
+ [key]
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # ── Class-level plugin registry ────────────────────────────────────────
126
+
127
+ @registry = {}
128
+
129
+ class << self
130
+ include Superkick::Registry
131
+
132
+ def register(klass)
133
+ raise ArgumentError, "#{klass} must define self.type" unless klass.respond_to?(:type)
134
+ key = klass.type.to_sym
135
+ raise ArgumentError, "Monitor type :#{key} already registered" if @registry.key?(key)
136
+
137
+ @registry[key] = klass
138
+ Probe.register(klass.probe_class) if klass.probe_class
139
+ end
140
+
141
+ def deregister(type)
142
+ key = type.to_sym
143
+ klass = @registry.delete(key)
144
+ Probe.deregister(key) if klass&.probe_class
145
+ end
146
+
147
+ def registered
148
+ @registry.dup.freeze
149
+ end
150
+
151
+ def lookup(type)
152
+ @registry[type.to_sym] or raise ArgumentError, "Unknown monitor type: #{type.inspect}"
153
+ end
154
+
155
+ # Convenience: runs all registered probes against an environment snapshot.
156
+ # Delegates to Probe.detect_all.
157
+ def detect_all(environment:)
158
+ Probe.detect_all(environment:)
159
+ end
160
+
161
+ # Convenience: collects all environment actions from registered probes.
162
+ # Delegates to Probe.all_environment_actions.
163
+ def all_environment_actions
164
+ Probe.all_environment_actions
165
+ end
166
+
167
+ # Returns the nested Probe class if one is defined, nil otherwise.
168
+ # Subclasses can override for explicit control.
169
+ def probe_class
170
+ const_defined?(:Probe, false) ? const_get(:Probe) : nil
171
+ end
172
+
173
+ # Fill in missing config values from the agent's environment snapshot.
174
+ # Subclasses override to infer values from the environment data
175
+ # (e.g. repo/branch from git remotes, story ID from branch name).
176
+ #
177
+ # Called by the Supervisor before constructing a monitor instance,
178
+ # so it works for all config sources (YAML, probes, spawners, MCP).
179
+ #
180
+ # Must not overwrite explicitly provided values.
181
+ # @param config [Hash] partial config (may be empty)
182
+ # @param environment [Hash] environment snapshot from the agent
183
+ # @return [Hash] config with missing values filled in
184
+ def resolve_config(config, environment: {})
185
+ config
186
+ end
187
+ end
188
+
189
+ # ── Instance ──────────────────────────────────────────────────────────
190
+
191
+ attr_reader :agent, :server_context
192
+
193
+ def initialize(name:, config:, handler:, agent: nil, server_context: {})
194
+ super(name:, config:, handler:)
195
+ @agent = agent
196
+ @server_context = server_context
197
+ end
198
+
199
+ # Emit an event. Stamps monitor_type and monitor_name, then delegates
200
+ # to the handler.
201
+ def dispatch(event)
202
+ full_event = event.merge(
203
+ monitor_type: self.class.type.to_s,
204
+ monitor_name: @name
205
+ )
206
+
207
+ @handler.handle(event: full_event)
208
+ end
209
+
210
+ def log_tag
211
+ agent_id = @agent&.id || "server"
212
+ "#{self.class.type}(#{@name}):#{agent_id}"
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Holds long-lived notifier instances so stateful notifiers can track
5
+ # per-agent state across events (e.g. Slack thread_ts for threading).
6
+ #
7
+ # Created once at server startup and passed to all server-side components
8
+ # via constructor injection. Stateless notifiers work unchanged — state
9
+ # is opt-in via `stateful?`.
10
+ #
11
+ # When an agent's event stream ends (completed, failed, timed_out,
12
+ # terminated), `agent_finished` is called automatically so stateful
13
+ # notifiers can clean up per-agent state.
14
+ #
15
+ # Accepts an optional `store:` (AgentStore) to enrich payloads with
16
+ # agent context (team_id, team_role, spawner_name, etc.).
17
+ class NotificationDispatcher
18
+ TERMINAL_EVENT_TYPES = %w[
19
+ agent_completed agent_failed agent_timed_out agent_terminated
20
+ team_completed team_failed team_timed_out
21
+ ].freeze
22
+
23
+ # Structural spawn_info fields (agent metadata, not integration context).
24
+ # Integration context is now stored in spawn_info[:context] and rehydrated.
25
+ SPAWN_INFO_METADATA_FIELDS = %i[
26
+ spawner_name parent_agent_id workflow_depth workflow_source
27
+ ].freeze
28
+
29
+ def initialize(state_store:, store: nil, notifications: Superkick.config.notifications, notifiers: nil,
30
+ internal_notifiers: nil)
31
+ @notifiers = notifiers || build_notifiers(notifications:, state_store:)
32
+ # Internal notifiers are always present regardless of config (e.g. Team::LogNotifier)
33
+ @notifiers.concat(internal_notifiers) if internal_notifiers
34
+ @store = store
35
+ @state_store = state_store
36
+ @agent_notifiers = {} # agent_id => { name => [notifier, events_filter] }
37
+ @agent_notifiers_mutex = Mutex.new
38
+ end
39
+
40
+ # Dispatch an event to all configured notifiers in a background thread.
41
+ # Each notifier is isolated — one failure doesn't prevent others.
42
+ def dispatch(event:, agent_id:, rendered_prompt: nil, message: nil)
43
+ Thread.new do
44
+ payload = build_payload(event:, agent_id:, rendered_prompt:, message:)
45
+ event_type = payload[:event_type]
46
+
47
+ fire_notifiers(@notifiers, payload, event_type)
48
+
49
+ # Per-agent notifiers
50
+ agent_notifiers = @agent_notifiers_mutex.synchronize { @agent_notifiers[agent_id]&.values&.dup }
51
+ fire_notifiers(agent_notifiers, payload, event_type) if agent_notifiers
52
+
53
+ # Call agent_finished on stateful notifiers after terminal events
54
+ if TERMINAL_EVENT_TYPES.include?(event_type)
55
+ finish_agent(agent_id)
56
+ end
57
+ rescue => e
58
+ Superkick.logger.error("notifier") { "dispatch error: #{e.message}" }
59
+ end
60
+ end
61
+
62
+ # Notify stateful notifiers that an agent's event stream is over.
63
+ # Called automatically after terminal events, but can also be called
64
+ # explicitly by the Supervisor during cleanup.
65
+ def agent_finished(agent_id)
66
+ @notifiers.each do |notifier, _|
67
+ next unless notifier.stateful?
68
+
69
+ notifier.agent_finished(agent_id:)
70
+ rescue => e
71
+ Superkick.logger.error("notifier") { "#{notifier.class.type} agent_finished failed: #{e.message}" }
72
+ end
73
+
74
+ # Clean up per-agent notifiers
75
+ agent_notifiers = @agent_notifiers_mutex.synchronize { @agent_notifiers.delete(agent_id) }
76
+ return unless agent_notifiers
77
+
78
+ agent_notifiers.each_value do |notifier, _|
79
+ next unless notifier.stateful?
80
+
81
+ notifier.agent_finished(agent_id:)
82
+ rescue => e
83
+ Superkick.logger.error("notifier") { "#{notifier.class.type} per-agent agent_finished failed: #{e.message}" }
84
+ end
85
+ end
86
+
87
+ # Build and register per-agent notifiers from the agent's notifier configs.
88
+ # Called by AgentSpawner after attaching notifier configs to the agent.
89
+ def register_agent_notifiers(agent_id)
90
+ agent = @store&.get(agent_id.to_s)
91
+ return unless agent&.notifiers&.any?
92
+
93
+ named = {}
94
+ agent.notifiers.each do |name, config|
95
+ pair = build_one_notifier(config)
96
+ named[name.to_sym] = pair if pair
97
+ end
98
+ return if named.empty?
99
+
100
+ @agent_notifiers_mutex.synchronize { @agent_notifiers[agent_id] = named }
101
+ end
102
+
103
+ # Add a single per-agent notifier by name. Called by Control::Server
104
+ # when superkick_add_notifier is invoked on a running agent.
105
+ def add_agent_notifier(agent_id, name)
106
+ agent = @store&.get(agent_id.to_s)
107
+ return unless agent
108
+
109
+ config = agent.notifier_config(name)
110
+ return unless config
111
+
112
+ pair = build_one_notifier(config)
113
+ return unless pair
114
+
115
+ @agent_notifiers_mutex.synchronize do
116
+ @agent_notifiers[agent_id] ||= {}
117
+ @agent_notifiers[agent_id][name.to_sym] = pair
118
+ end
119
+ end
120
+
121
+ # Remove a single per-agent notifier by name. Called by Control::Server
122
+ # when superkick_remove_notifier is invoked on a running agent.
123
+ # Calls agent_finished on stateful notifiers for proper cleanup.
124
+ def remove_agent_notifier(agent_id, name)
125
+ pair = @agent_notifiers_mutex.synchronize do
126
+ @agent_notifiers.dig(agent_id, name.to_sym)&.tap do
127
+ @agent_notifiers[agent_id]&.delete(name.to_sym)
128
+ remaining = @agent_notifiers[agent_id]
129
+ @agent_notifiers.delete(agent_id) if remaining && remaining.empty?
130
+ end
131
+ end
132
+ return unless pair
133
+
134
+ notifier, _ = pair
135
+ return unless notifier.stateful?
136
+
137
+ notifier.agent_finished(agent_id:)
138
+ rescue => e
139
+ Superkick.logger.error("notifier") { "#{notifier.class.type} per-agent agent_finished failed: #{e.message}" }
140
+ end
141
+
142
+ private
143
+
144
+ alias_method :finish_agent, :agent_finished
145
+
146
+ def fire_notifiers(notifier_pairs, payload, event_type)
147
+ notifier_pairs.each do |notifier, events_filter|
148
+ next if events_filter && !events_filter.include?(event_type)
149
+
150
+ notifier.notify(payload)
151
+ rescue => e
152
+ Superkick.logger.error("notifier") { "#{notifier.class.type} failed: #{e.message}" }
153
+ end
154
+ end
155
+
156
+ def build_payload(event:, agent_id:, rendered_prompt:, message:)
157
+ {
158
+ event_type: event[:event_type].to_s,
159
+ monitor: MonitorDrop.new(
160
+ name: event[:monitor_name],
161
+ type: event[:monitor_type]
162
+ ),
163
+ agent_id: agent_id.to_s,
164
+ message: message || summary_for(event),
165
+ rendered_prompt:,
166
+ timestamp: Time.now.utc.iso8601,
167
+ context: build_context(agent_id:, event:)
168
+ }
169
+ end
170
+
171
+ def build_context(agent_id:, event:)
172
+ context = {}
173
+
174
+ agent = @store&.get(agent_id.to_s)
175
+
176
+ if agent
177
+ agent_data = {id: agent.id}
178
+ agent_data[:role] = agent.role if agent.role
179
+ agent_data[:team_role] = agent.team_role if agent.team_role
180
+ agent_data[:claimed] = true if agent.claimed?
181
+
182
+ if agent.spawn_info
183
+ agent_data[:spawned_at] = agent.spawn_info[:spawned_at] if agent.spawn_info[:spawned_at]
184
+ end
185
+
186
+ cost_data = agent.cost.to_h
187
+ agent_data[:cost_usd] = cost_data[:total_cost_usd] if cost_data[:total_cost_usd] > 0
188
+
189
+ # Nest goal under agent — event-level goal_status overrides agent state
190
+ goal_status = (event[:goal_status] || agent.goal_status)&.to_s
191
+ if goal_status && !goal_status.empty?
192
+ goal_data = {status: goal_status}
193
+ goal_data[:summary] = agent.goal_summary if agent.goal_summary
194
+ agent_data[:goal] = GoalDrop.new(goal_data)
195
+ end
196
+
197
+ context[:agent] = AgentDrop.new(agent_data)
198
+
199
+ if agent.team_id
200
+ team_data = {id: agent.team_id}
201
+ team_data[:members] = event[:team_members] if event[:team_members]
202
+ context[:team] = TeamDrop.new(team_data)
203
+ end
204
+
205
+ if agent.spawn_info
206
+ SPAWN_INFO_METADATA_FIELDS.each do |field|
207
+ next if field == :spawner_name
208
+ value = agent.spawn_info[field]
209
+ context[field] = value if value
210
+ end
211
+
212
+ if agent.spawn_info[:spawner_name]
213
+ context[:spawner] = SpawnerDrop.new(name: agent.spawn_info[:spawner_name])
214
+ end
215
+
216
+ # Rehydrate integration context (Drops become real objects)
217
+ if agent.spawn_info[:context]
218
+ rehydrated = Superkick::Drop.rehydrate(agent.spawn_info[:context])
219
+ rehydrated.each { |k, v| context[k] = v unless context.key?(k) }
220
+ end
221
+ end
222
+ end
223
+
224
+ # Forward event-level fields (not agent state — these describe the triggering event)
225
+ context[:budget] = event[:budget] if event[:budget]
226
+ context[:spent] = event[:spent] if event[:spent]
227
+ context[:approval_id] = event[:approval_id].to_s if event[:approval_id]
228
+ context[:repository_name] = event[:repository_name].to_s if event[:repository_name]
229
+ context[:artifact_name] = event[:artifact_name].to_s if event[:artifact_name]
230
+ context[:kind] = event[:kind] if event[:kind]
231
+ context[:target_agent_id] = event[:target_agent_id].to_s if event[:target_agent_id]
232
+ context[:slack_thread_ts] = event[:slack_thread_ts] if event[:slack_thread_ts]
233
+
234
+ # Build team Drop from event-level fields when no agent context exists
235
+ if !context[:team] && event[:team_members]
236
+ context[:team] = TeamDrop.new(members: event[:team_members])
237
+ end
238
+
239
+ context
240
+ end
241
+
242
+ def summary_for(event)
243
+ type = event[:event_type].to_s
244
+ monitor = event[:monitor_name] || event[:monitor_type]
245
+ "Superkick: #{type} from #{monitor}"
246
+ end
247
+
248
+ # Build a single [notifier, events_filter] pair from a config hash.
249
+ # Returns nil if the notifier type is unknown.
250
+ def build_one_notifier(config)
251
+ type_name = config[:type]
252
+ klass = Notifier.registered[type_name.to_sym]
253
+ unless klass
254
+ Superkick.logger.warn("notifier") { "Unknown notifier type: #{type_name.inspect}" }
255
+ return nil
256
+ end
257
+ events_filter = config[:events]&.map(&:to_s)
258
+ [klass.new(state_store: @state_store, **config.except(:type, :events, :name, :privileged)), events_filter]
259
+ end
260
+
261
+ # Build notifier instances from the configured notification list.
262
+ # Returns an array of [notifier, event_filter] pairs.
263
+ # event_filter is nil (all events) or an Array of event type strings.
264
+ def build_notifiers(notifications:, state_store:)
265
+ configs = notifications
266
+ configs = [{type: "terminal_bell"}] if configs.empty?
267
+
268
+ configs.filter_map do |config|
269
+ type_name = config[:type]
270
+ klass = Notifier.registered[type_name.to_sym]
271
+ unless klass
272
+ Superkick.logger.warn("notifier") { "Unknown notifier type: #{type_name.inspect}" }
273
+ next
274
+ end
275
+ events_filter = config[:events]&.map(&:to_s)
276
+ [klass.new(state_store:, **config.except(:type, :events)), events_filter]
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "liquid"
4
+
5
+ module Superkick
6
+ # Notifier — base class for notification channels.
7
+ #
8
+ # Notifications fire on two kinds of events:
9
+ # 1. Monitor injections (from Injector after a successful injection)
10
+ # 2. Agent lifecycle events (from Supervisor/AgentSpawner)
11
+ #
12
+ # Subclass contract:
13
+ # self.type → unique Symbol (e.g. :terminal_bell, :command)
14
+ # self.templates_dir → path to bundled Liquid templates (optional)
15
+ # notify(payload) → called with a Hash of event details; should not raise
16
+ # liquid do ... end → declare Liquid filters, blocks, context (optional)
17
+ #
18
+ # Subclasses register themselves:
19
+ # Superkick::Notifier.register(MyNotifier)
20
+ #
21
+ # Configuration (config.yml):
22
+ #
23
+ # notifications:
24
+ # - type: terminal_bell
25
+ # - type: command
26
+ # run: "notify-send 'Superkick' '$SUPERKICK_MESSAGE'"
27
+ # events:
28
+ # - agent_blocked
29
+ # - agent_completed
30
+ #
31
+ # The optional `events:` filter restricts which event types a notifier
32
+ # receives. When absent, the notifier receives all events.
33
+ #
34
+ # When notifications is empty or not set, defaults to [{ type: :terminal_bell }].
35
+ #
36
+ # Agent lifecycle event types (see LIFECYCLE_EVENTS constant for full list):
37
+ # - agent_spawned, agent_completed, agent_failed, agent_timed_out,
38
+ # agent_blocked, agent_stalled, agent_terminated, agent_claimed,
39
+ # agent_unclaimed, agent_pending_approval
40
+ # - workflow_triggered, workflow_iterations_exceeded
41
+ # - budget_warning, budget_exceeded
42
+ # - team_created, team_completed, team_failed, team_timed_out,
43
+ # worker_spawned, teammate_message, teammate_blocker
44
+ # - attach_promoted, attach_demoted, attach_idle_timeout, attach_force_takeover
45
+ class Notifier
46
+ include Superkick::Liquid
47
+
48
+ # ── Registry (stores classes, keyed by type) ─────────────────────────
49
+ @registry = {}
50
+
51
+ LIFECYCLE_EVENTS = %i[
52
+ agent_spawned
53
+ agent_completed
54
+ agent_failed
55
+ agent_timed_out
56
+ agent_blocked
57
+ agent_stalled
58
+ agent_terminated
59
+ agent_claimed
60
+ agent_unclaimed
61
+ agent_pending_approval
62
+ workflow_triggered
63
+ workflow_iterations_exceeded
64
+ budget_warning
65
+ budget_exceeded
66
+ team_created
67
+ team_completed
68
+ team_failed
69
+ team_timed_out
70
+ worker_spawned
71
+ teammate_message
72
+ teammate_blocker
73
+ attach_promoted
74
+ attach_demoted
75
+ attach_idle_timeout
76
+ attach_force_takeover
77
+ agent_update
78
+ artifact_published
79
+ ].freeze
80
+
81
+ class << self
82
+ include Superkick::Registry
83
+
84
+ def register(notifier_class)
85
+ key = notifier_class.type
86
+ raise ArgumentError, "Notifier :#{key} is already registered" if @registry.key?(key)
87
+ @registry[key] = notifier_class
88
+ end
89
+
90
+ def lookup(name)
91
+ @registry[name.to_sym] or raise ArgumentError, "Unknown notifier: #{name.inspect}"
92
+ end
93
+
94
+ def registered
95
+ @registry.dup.freeze
96
+ end
97
+
98
+ private
99
+ end
100
+
101
+ # ── Class interface ────────────────────────────────────────────────
102
+
103
+ def self.type
104
+ raise NotImplementedError, "#{self}.type not implemented"
105
+ end
106
+
107
+ # Path to bundled Liquid templates. Override in subclasses.
108
+ def self.templates_dir = nil
109
+
110
+ # Setup wizard metadata. Override in subclasses that should appear in `superkick setup`.
111
+ def self.setup_label = nil
112
+ def self.setup_config = nil
113
+
114
+ # ── Instance interface ───────────────────────────────────────────────
115
+
116
+ def initialize(state_store:, **_opts)
117
+ @state_store = state_store
118
+ end
119
+
120
+ def notify(payload)
121
+ raise NotImplementedError, "#{self.class}#notify not implemented"
122
+ end
123
+
124
+ # Override to return true if this notifier tracks state across events.
125
+ # Stateful notifiers persist for the server's lifetime and receive
126
+ # agent_finished callbacks when an agent's event stream ends.
127
+ def stateful? = false
128
+
129
+ # Called when an agent's event stream is definitively over.
130
+ # Stateful notifiers use this to clean up per-agent state.
131
+ # Default no-op — only stateful notifiers need to implement this.
132
+ def agent_finished(agent_id:) = nil
133
+
134
+ # Build the accumulator for block tag rendering. Uses the context class
135
+ # from `liquid do { context { ... } }` if declared, otherwise a plain Hash.
136
+ def build_accumulator
137
+ context_class = self.class.liquid_config.context_class
138
+ context_class ? context_class.new : {}
139
+ end
140
+
141
+ # Render a notification template for the given payload.
142
+ # Returns { structured:, text: } via NotifierTemplate, or nil if no
143
+ # template is found.
144
+ #
145
+ # Context fields are merged into top-level assigns so templates can
146
+ # access Drops directly (e.g. `{{ team.id }}` instead of
147
+ # `{{ context.team.id }}`). Top-level payload keys take precedence
148
+ # on collision.
149
+ def render_notification(payload)
150
+ source = TemplateRenderer.resolve_notification(self.class, payload[:event_type])
151
+ return nil unless source
152
+
153
+ env = TemplateRenderer.environment_for(self.class)
154
+ assigns = build_template_assigns(payload)
155
+ accumulator = build_accumulator
156
+
157
+ NotifierTemplate.render(source:, environment: env, assigns:, accumulator:)
158
+ end
159
+
160
+ private
161
+
162
+ def build_template_assigns(payload)
163
+ context = payload[:context] || {}
164
+ # Context fields form the base; top-level payload keys win on collision
165
+ assigns = context.transform_keys(&:to_s)
166
+ payload.each do |k, v|
167
+ next if k == :context
168
+ assigns[k.to_s] = v
169
+ end
170
+ assigns
171
+ end
172
+ end
173
+ end