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.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- 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
|