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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # NotifierStateStore — injectable key-value state storage for stateful notifiers.
5
+ #
6
+ # Stateful notifiers (Slack, Datadog, Honeybadger) track per-agent state across
7
+ # events (e.g. Slack thread_ts for threading, spawned_at for duration computation).
8
+ # This class provides an explicit, injectable interface for that state.
9
+ #
10
+ # The Memory subclass preserves current behavior (in-memory, mutex-protected,
11
+ # lost on server restart). The hosted Rails app provides a database-backed
12
+ # implementation for persistence and multi-process sharing.
13
+ #
14
+ # Interface:
15
+ # get(notifier_type, key) → Hash or nil
16
+ # put(notifier_type, key, data) → void
17
+ # delete(notifier_type, key) → void
18
+ class NotifierStateStore
19
+ def get(notifier_type, key)
20
+ raise NotImplementedError, "#{self.class}#get not implemented"
21
+ end
22
+
23
+ def put(notifier_type, key, data)
24
+ raise NotImplementedError, "#{self.class}#put not implemented"
25
+ end
26
+
27
+ def delete(notifier_type, key)
28
+ raise NotImplementedError, "#{self.class}#delete not implemented"
29
+ end
30
+
31
+ # In-memory implementation — thread-safe, process-local, lost on restart.
32
+ # Default for local server mode.
33
+ class Memory < NotifierStateStore
34
+ def initialize
35
+ @data = {}
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ def get(notifier_type, key)
40
+ @mutex.synchronize { @data.dig(notifier_type, key)&.dup }
41
+ end
42
+
43
+ def put(notifier_type, key, data)
44
+ @mutex.synchronize do
45
+ @data[notifier_type] ||= {}
46
+ @data[notifier_type][key] = data
47
+ end
48
+ end
49
+
50
+ def delete(notifier_type, key)
51
+ @mutex.synchronize { @data[notifier_type]&.delete(key) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ module Superkick
6
+ # NotifierTemplate — renders Liquid templates with structured output via
7
+ # block tags. Block tags accumulate data onto a mutable accumulator
8
+ # (stored in Liquid registers), producing both structured and text output.
9
+ #
10
+ # Usage:
11
+ #
12
+ # block_class = NotifierTemplate.create_block_class(->(ctx, text, attrs) {
13
+ # ctx.blocks << { type: "header", text: text }
14
+ # })
15
+ #
16
+ # result = NotifierTemplate.render(
17
+ # source: template_source,
18
+ # environment: liquid_environment,
19
+ # assigns: { "event_type" => "agent_completed" },
20
+ # accumulator: SlackContext.new
21
+ # )
22
+ # result[:structured] # => SlackContext with populated blocks
23
+ # result[:text] # => rendered text outside block tags
24
+ class NotifierTemplate
25
+ # Generate a Liquid::Block subclass that accumulates structured output.
26
+ # The handler receives (accumulator, rendered_body_text, attributes) and
27
+ # pushes structured data onto the accumulator. The block renders as ""
28
+ # in the template output — structured results are read from the
29
+ # accumulator after rendering.
30
+ #
31
+ # Attribute values are resolved as Liquid expressions at render time:
32
+ # - Quoted values (`key: "literal"`) stay as literal strings
33
+ # - Bare identifiers (`key: issue.url`) are resolved from the
34
+ # template context, matching the behavior of Liquid's built-in
35
+ # {% render %} and {% include %} tags
36
+ def self.create_block_class(handler)
37
+ Class.new(::Liquid::Block) do
38
+ define_method(:render) do |context|
39
+ text = super(context).strip
40
+ attrs = evaluate_attributes(context)
41
+ handler.call(context.registers[:notifier_accumulator], text, attrs)
42
+ ""
43
+ end
44
+
45
+ private
46
+
47
+ define_method(:parse_raw_attributes) do
48
+ @_parsed_expressions ||= begin
49
+ markup = @markup.to_s.strip
50
+ return {} if markup.empty?
51
+
52
+ exprs = {}
53
+ markup.scan(::Liquid::TagAttributes) do |key, raw_value|
54
+ exprs[key] = ::Liquid::Expression.parse(raw_value)
55
+ end
56
+ exprs
57
+ end
58
+ end
59
+
60
+ define_method(:evaluate_attributes) do |context|
61
+ parse_raw_attributes.each_with_object({}) do |(key, expr), attrs|
62
+ value = context.evaluate(expr)
63
+ attrs[key] = value.to_s unless value.nil?
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ # Generate a Liquid::Tag subclass (bodyless — no end tag) that
70
+ # accumulates structured output. The handler receives
71
+ # (accumulator, attributes) and pushes structured data onto the
72
+ # accumulator. The tag renders as "" in the template output.
73
+ #
74
+ # Attribute values are resolved as Liquid expressions at render time
75
+ # (see create_block_class for details).
76
+ def self.create_tag_class(handler)
77
+ Class.new(::Liquid::Tag) do
78
+ define_method(:render) do |context|
79
+ attrs = evaluate_attributes(context)
80
+ handler.call(context.registers[:notifier_accumulator], attrs)
81
+ ""
82
+ end
83
+
84
+ private
85
+
86
+ define_method(:parse_raw_attributes) do
87
+ @_parsed_expressions ||= begin
88
+ markup = @markup.to_s.strip
89
+ return {} if markup.empty?
90
+
91
+ exprs = {}
92
+ markup.scan(::Liquid::TagAttributes) do |key, raw_value|
93
+ exprs[key] = ::Liquid::Expression.parse(raw_value)
94
+ end
95
+ exprs
96
+ end
97
+ end
98
+
99
+ define_method(:evaluate_attributes) do |context|
100
+ parse_raw_attributes.each_with_object({}) do |(key, expr), attrs|
101
+ value = context.evaluate(expr)
102
+ attrs[key] = value.to_s unless value.nil?
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Render a template, returning { structured:, text: }.
109
+ #
110
+ # @param source [String] Liquid template source
111
+ # @param environment [Liquid::Environment] pre-built environment with filters and tags
112
+ # @param assigns [Hash] template variables (string keys)
113
+ # @param accumulator [Object] mutable accumulator for block tags
114
+ # @return [Hash] { structured: accumulator, text: rendered_text }
115
+ def self.render(source:, environment:, assigns:, accumulator:)
116
+ template = ::Liquid::Template.parse(source, environment:)
117
+ text = template.render(assigns, registers: {notifier_accumulator: accumulator}).strip
118
+ {structured: accumulator, text:}
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Notifiers
5
+ # Runs a shell command when an injection or lifecycle event occurs.
6
+ #
7
+ # The command string is executed via the system shell. Event details
8
+ # are passed as environment variables so commands can build rich
9
+ # notifications:
10
+ #
11
+ # SUPERKICK_EVENT_TYPE — e.g. "ci_failure", "pr_comment"
12
+ # SUPERKICK_MONITOR_TYPE — e.g. "github", "shell"
13
+ # SUPERKICK_MONITOR_NAME — e.g. "github", "disk_check"
14
+ # SUPERKICK_AGENT_ID — the agent that received the injection
15
+ # SUPERKICK_MESSAGE — a short human-readable summary
16
+ # SUPERKICK_BODY — rendered template text (empty if no template)
17
+ #
18
+ # The command string also supports $VARIABLE interpolation for
19
+ # inline use:
20
+ #
21
+ # notifications:
22
+ # - type: command
23
+ # run: "notify-send 'Superkick' '$SUPERKICK_MESSAGE'"
24
+ # - type: command
25
+ # run: "curl -X POST $SLACK_WEBHOOK -d '{\"text\": \"$SUPERKICK_BODY\"}'"
26
+ #
27
+ # Template support:
28
+ # Templates can define additional env vars using the {% env %} block tag:
29
+ #
30
+ # {% env key: "pr_title" %}{{ pull_request.title }}{% endenv %}
31
+ # {% env key: "repo" %}{{ repo }}{% endenv %}
32
+ # PR #{{ pull_request.number }} needs review.
33
+ #
34
+ # The {% env %} blocks populate SUPERKICK_PR_TITLE, SUPERKICK_REPO, etc.
35
+ # Text outside blocks becomes SUPERKICK_BODY. All names are uppercased
36
+ # and prefixed with SUPERKICK_ automatically.
37
+ #
38
+ # Templates are resolved from:
39
+ # 1. ~/.superkick/templates/notifications/command/<event_type>.liquid
40
+ # 2. ~/.superkick/templates/notifications/command/default.liquid
41
+ #
42
+ # Commands are killed after timeout_seconds (default 10) to prevent
43
+ # hung notification processes from accumulating.
44
+ class Command < Notifier
45
+ def self.type = :command
46
+
47
+ def self.setup_label = "Command"
48
+
49
+ def self.setup_config
50
+ <<~YAML
51
+ - type: command
52
+ run: "notify-send 'Superkick' '$SUPERKICK_MESSAGE'"
53
+ # timeout_seconds: 10 # kill command after this many seconds
54
+ # events: # restrict which events fire this notifier
55
+ # - agent_completed
56
+ # - agent_failed
57
+ # - agent_blocked
58
+ YAML
59
+ end
60
+
61
+ DEFAULT_TIMEOUT = 10
62
+
63
+ liquid do
64
+ context do
65
+ attribute :env_vars, default: -> { {} }
66
+ end
67
+
68
+ block :env do |ctx, text, attrs|
69
+ name = attrs["key"]
70
+ ctx.env_vars[name] = text if name
71
+ end
72
+ end
73
+
74
+ def initialize(run: nil, timeout_seconds: DEFAULT_TIMEOUT, **opts)
75
+ super(**opts)
76
+ @run = run
77
+ @timeout_seconds = timeout_seconds.to_i
78
+ end
79
+
80
+ def notify(payload)
81
+ unless @run
82
+ Superkick.logger.warn("notifier:command") { "No 'run' command configured — skipping" }
83
+ return
84
+ end
85
+
86
+ env = build_env(payload)
87
+ result = ProcessRunner.spawn(@run, timeout: @timeout_seconds, env:)
88
+
89
+ if result[:timed_out]
90
+ Superkick.logger.warn("notifier:command") { "Command timed out after #{@timeout_seconds}s: #{@run}" }
91
+ elsif result[:status] && !result[:status].success?
92
+ Superkick.logger.warn("notifier:command") { "Command exited #{result[:status].exitstatus}" }
93
+ end
94
+ rescue Errno::ENOENT => e
95
+ Superkick.logger.warn("notifier:command") { "Command not found: #{e.message}" }
96
+ end
97
+
98
+ private
99
+
100
+ def build_env(payload)
101
+ monitor = payload[:monitor]
102
+ env = {
103
+ "SUPERKICK_EVENT_TYPE" => payload[:event_type].to_s,
104
+ "SUPERKICK_MONITOR_TYPE" => monitor&.type.to_s,
105
+ "SUPERKICK_MONITOR_NAME" => monitor&.name.to_s,
106
+ "SUPERKICK_AGENT_ID" => payload[:agent_id].to_s,
107
+ "SUPERKICK_MESSAGE" => payload[:message].to_s
108
+ }
109
+
110
+ template_result = render_notification(payload)
111
+ if template_result
112
+ env["SUPERKICK_BODY"] = template_result[:text]
113
+ template_result[:structured].env_vars.each do |name, value|
114
+ env["SUPERKICK_#{name.upcase}"] = value
115
+ end
116
+ end
117
+
118
+ env
119
+ end
120
+ end
121
+ end
122
+
123
+ Notifier.register(Notifiers::Command)
124
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Notifiers
5
+ # Writes a BEL character (\a) to the server's original TTY.
6
+ #
7
+ # This is the zero-config default notifier. Most terminal emulators
8
+ # respond to BEL with a visual or audible alert:
9
+ # - iTerm2 bounces the dock icon
10
+ # - tmux marks the window with activity
11
+ # - GNOME Terminal flashes the tab title
12
+ # - Windows Terminal flashes the taskbar
13
+ #
14
+ # Config (config.yml):
15
+ # notifications:
16
+ # - type: terminal_bell
17
+ class TerminalBell < Notifier
18
+ def self.type = :terminal_bell
19
+
20
+ def self.setup_label = "Terminal Bell"
21
+
22
+ def self.setup_config
23
+ <<~YAML
24
+ - type: terminal_bell
25
+ YAML
26
+ end
27
+
28
+ def notify(payload)
29
+ # Write BEL to stderr — the server's stderr is typically
30
+ # connected to the controlling terminal (unless daemonized,
31
+ # in which case it's redirected to the log file and the bell
32
+ # is a harmless no-op).
33
+ $stderr.write("\a")
34
+ rescue IOError
35
+ # stderr closed or redirected — nothing to do.
36
+ end
37
+ end
38
+ end
39
+
40
+ Notifier.register(Notifiers::TerminalBell)
41
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Manages writing PTY output to a per-agent log file.
5
+ # Raw bytes (including ANSI escape codes) are written in append mode
6
+ # with sync flushing so `tail -f` works in real time.
7
+ #
8
+ # Simple size-based rotation: one rotated file (*.1). When the log
9
+ # exceeds max_size, the current file becomes .1 and a new file starts.
10
+ class OutputLogger
11
+ MAX_SIZE = 10 * 1024 * 1024 # 10 MB default
12
+
13
+ def initialize(agent_id:, max_size: MAX_SIZE, log_dir: Superkick.config.output_logs_dir)
14
+ @path = File.join(log_dir, "#{agent_id}.log")
15
+ @max_size = max_size
16
+ @file = nil
17
+ @bytes_written = 0
18
+ end
19
+
20
+ def start
21
+ FileUtils.mkdir_p(File.dirname(@path))
22
+ @file = File.open(@path, "ab")
23
+ @file.sync = true
24
+ @bytes_written = @file.size
25
+ self
26
+ end
27
+
28
+ def write(data)
29
+ return unless @file
30
+ rotate! if @bytes_written + data.bytesize > @max_size
31
+ @file.write(data)
32
+ @bytes_written += data.bytesize
33
+ end
34
+
35
+ def close
36
+ @file&.close
37
+ @file = nil
38
+ end
39
+
40
+ attr_reader :path
41
+
42
+ private
43
+
44
+ def rotate!
45
+ @file.close
46
+ rotated = "#{@path}.1"
47
+ FileUtils.rm_f(rotated)
48
+ File.rename(@path, rotated)
49
+ @file = File.open(@path, "ab")
50
+ @file.sync = true
51
+ @bytes_written = 0
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "liquid"
4
+
5
+ module Superkick
6
+ # Poller — abstract base class for all polling event sources.
7
+ #
8
+ # Provides the shared run loop, error handling, backoff, and config
9
+ # validation used by both Monitor (agent-bound) and Spawner (server-level).
10
+ #
11
+ # Subclass contract:
12
+ # self.type → unique Symbol
13
+ # self.description → human-readable summary (optional)
14
+ # self.required_config → Array of Symbol keys validated before start
15
+ # self.templates_dir → path to Liquid templates (optional)
16
+ # liquid do ... end → declare Liquid filters, blocks, context (optional)
17
+ # tick → called each interval; use dispatch(event) to emit
18
+ # on_start → optional hook called once before the run loop
19
+ class Poller
20
+ include Superkick::Liquid
21
+
22
+ # Raised inside tick to signal API rate-limit; triggers backoff.
23
+ class RateLimited < StandardError; end
24
+
25
+ # Raised inside tick to signal a permanent configuration error; exits thread.
26
+ class FatalError < StandardError; end
27
+
28
+ attr_reader :name
29
+
30
+ def initialize(name:, config:, handler:)
31
+ @name = name.to_s
32
+ @config = config
33
+ @handler = handler
34
+ @running = false
35
+ end
36
+
37
+ # Config accessor — always use symbol keys.
38
+ def [](key)
39
+ @config[key.to_sym]
40
+ end
41
+
42
+ # Start the blocking run loop (call from a dedicated thread).
43
+ def run
44
+ begin
45
+ validate_config!
46
+ on_start
47
+ rescue FatalError => e
48
+ Superkick.logger.error(log_tag) { "Fatal startup error: #{e.message}" }
49
+ return
50
+ end
51
+ @running = true
52
+
53
+ loop do
54
+ flush_pending_events
55
+ begin
56
+ tick
57
+ rescue RateLimited => e
58
+ Superkick.logger.warn(log_tag) { "Rate limited: #{e.message}; backing off #{Superkick.config.rate_limit_backoff}s" }
59
+ sleep(Superkick.config.rate_limit_backoff)
60
+ rescue FatalError => e
61
+ Superkick.logger.error(log_tag) { "Fatal error: #{e.message}; stopping" }
62
+ break
63
+ rescue => e
64
+ Superkick.logger.error(log_tag) { "Unexpected error: #{e.message}\n#{e.backtrace.first(3).join("\n")}" }
65
+ sleep(Superkick.config.error_backoff)
66
+ end
67
+
68
+ sleep(Superkick.config.poll_interval)
69
+ end
70
+ ensure
71
+ @running = false
72
+ end
73
+
74
+ def stop
75
+ @running = false
76
+ end
77
+
78
+ # Called once before the run loop starts.
79
+ def on_start
80
+ end
81
+
82
+ # Subclasses must implement.
83
+ def tick
84
+ raise NotImplementedError, "#{self.class}#tick not implemented"
85
+ end
86
+
87
+ def log_tag
88
+ "#{self.class.type}(#{@name})"
89
+ end
90
+
91
+ # Class-level interface that subclasses must implement:
92
+ # self.type → Symbol
93
+ # self.required_config → Array of Symbol keys
94
+ # self.templates_dir → path or nil
95
+ # self.description → String or nil
96
+
97
+ class << self
98
+ def type
99
+ raise NotImplementedError, "#{self}.type not defined"
100
+ end
101
+
102
+ def description = nil
103
+ def required_config = []
104
+ def templates_dir = nil
105
+ def setup_label = nil
106
+ def setup_config = nil
107
+
108
+ def event_types
109
+ dir = templates_dir
110
+ return [] unless dir && File.directory?(dir)
111
+ Dir.glob(File.join(dir, "*.liquid")).map { |f| File.basename(f, ".liquid") }.sort
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def flush_pending_events
118
+ @handler.flush_pending
119
+ end
120
+
121
+ def validate_config!
122
+ missing = self.class.required_config.reject { |k| @config.key?(k.to_sym) }
123
+ raise FatalError, "Missing required config keys: #{missing.join(", ")}" unless missing.empty?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Unified subprocess execution with reliable timeouts.
5
+ #
6
+ # Ruby's Timeout.timeout uses Thread.raise to deliver async exceptions,
7
+ # which cannot reliably interrupt C-level blocking calls like waitpid
8
+ # or IO#read. This module avoids Timeout entirely:
9
+ #
10
+ # .run — captures stdout+stderr, returns {output:, status:, timed_out:}
11
+ # .spawn — fire-and-forget, no output capture, kills on timeout
12
+ #
13
+ # Both methods SIGTERM the child on timeout and reap it to prevent
14
+ # zombies. All timeouts use CLOCK_MONOTONIC to avoid wall-clock skew.
15
+ module ProcessRunner
16
+ # Run a command, capture combined stdout+stderr, and return the result.
17
+ #
18
+ # @param cmd [String] shell command to execute
19
+ # @param timeout [Integer] seconds before SIGTERM (default 30)
20
+ # @param env [Hash] environment variables (default {})
21
+ # @param chdir [String, nil] working directory (default nil)
22
+ # @return [Hash] { output: String, status: Process::Status|nil, timed_out: Boolean }
23
+ def self.run(cmd, timeout: 30, env: {}, chdir: nil)
24
+ options = {err: [:child, :out]}
25
+ options[:chdir] = chdir if chdir
26
+
27
+ output = +""
28
+ timed_out = false
29
+
30
+ IO.popen(env, cmd, **options) do |io|
31
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
32
+
33
+ loop do
34
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ if remaining <= 0
36
+ Process.kill("TERM", io.pid)
37
+ timed_out = true
38
+ break
39
+ end
40
+
41
+ ready = IO.select([io], nil, nil, [remaining, 0.5].min)
42
+ if ready
43
+ chunk = io.read_nonblock(8192, exception: false)
44
+ break if chunk == :wait_readable
45
+ break if chunk.nil? # EOF
46
+ output << chunk
47
+ end
48
+ end
49
+
50
+ io.close
51
+ end
52
+
53
+ {output:, status: $?, timed_out:}
54
+ rescue SystemCallError, IOError => e
55
+ {output: e.message, status: nil, timed_out: false}
56
+ end
57
+
58
+ # Spawn a command without capturing output. Blocks until the
59
+ # process exits or the timeout is reached, then returns.
60
+ #
61
+ # @param cmd [String] shell command to execute
62
+ # @param timeout [Integer] seconds before SIGTERM (default 10)
63
+ # @param env [Hash] environment variables (default {})
64
+ # @return [Hash] { status: Process::Status|nil, timed_out: Boolean }
65
+ def self.spawn(cmd, timeout: 10, env: {})
66
+ pid = Process.spawn(env, cmd, %i[out err] => File::NULL)
67
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
68
+
69
+ loop do
70
+ _pid, status = Process.waitpid2(pid, Process::WNOHANG)
71
+ return {status:, timed_out: false} if status
72
+
73
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
74
+ Process.kill("TERM", pid)
75
+ _, status = Process.waitpid2(pid)
76
+ return {status:, timed_out: true}
77
+ end
78
+
79
+ sleep 0.1
80
+ end
81
+ rescue Errno::ENOENT => e
82
+ raise e # let caller handle command-not-found
83
+ rescue Errno::ESRCH, Errno::ECHILD
84
+ {status: nil, timed_out: false}
85
+ end
86
+ end
87
+ end