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,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
# Orchestrates server startup, signal handling, and shutdown.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# Superkick::Server.start(daemonize: false)
|
|
10
|
+
class Server
|
|
11
|
+
def self.start(daemonize: false)
|
|
12
|
+
new(daemonize:).run
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(daemonize: false)
|
|
16
|
+
@daemonize = daemonize
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
check_already_running!
|
|
21
|
+
|
|
22
|
+
daemonize! if @daemonize
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(Superkick.config.base_dir)
|
|
25
|
+
write_pid_file
|
|
26
|
+
|
|
27
|
+
Superkick.logger.info("server") { "Superkick server starting (v#{VERSION})" }
|
|
28
|
+
|
|
29
|
+
Superkick.load_config!
|
|
30
|
+
Superkick.logger.level = Superkick.config.log_level
|
|
31
|
+
|
|
32
|
+
store = AgentStore.new
|
|
33
|
+
buffer_client = Buffer.client_from(store:)
|
|
34
|
+
attach_relay_store = build_attach_relay_store
|
|
35
|
+
recover_agents(store, buffer_client)
|
|
36
|
+
notifier_state_store = NotifierStateStore::Memory.new
|
|
37
|
+
team_log_store = Team::LogStore.new
|
|
38
|
+
team_log_notifier = Team::LogNotifier.new(team_log_store:, state_store: notifier_state_store)
|
|
39
|
+
notification_dispatcher = NotificationDispatcher.new(
|
|
40
|
+
store:, state_store: notifier_state_store,
|
|
41
|
+
internal_notifiers: [[team_log_notifier, nil]]
|
|
42
|
+
)
|
|
43
|
+
injector = Injector.new(store:, notification_dispatcher:, buffer_client:)
|
|
44
|
+
team_artifact_store = Team::ArtifactStore.new
|
|
45
|
+
approval_store = Spawn::ApprovalStore.new
|
|
46
|
+
budget_checker = BudgetChecker.new(store:)
|
|
47
|
+
supervisor = Supervisor.new(
|
|
48
|
+
store:, injector:, buffer_client:,
|
|
49
|
+
approval_store:,
|
|
50
|
+
team_log_store:,
|
|
51
|
+
notification_dispatcher:
|
|
52
|
+
)
|
|
53
|
+
agent_spawner = Spawn::AgentSpawner.new(store:, notification_dispatcher:, supervisor:,
|
|
54
|
+
runtime: Superkick.config.agent_runtime)
|
|
55
|
+
supervisor.agent_spawner = agent_spawner
|
|
56
|
+
control_server = Control::Server.new(
|
|
57
|
+
store:,
|
|
58
|
+
injector:,
|
|
59
|
+
supervisor:,
|
|
60
|
+
buffer_client:,
|
|
61
|
+
agent_spawner:,
|
|
62
|
+
approval_store:,
|
|
63
|
+
budget_checker:,
|
|
64
|
+
team_log_store:,
|
|
65
|
+
team_artifact_store:,
|
|
66
|
+
notification_dispatcher:,
|
|
67
|
+
attach_relay_store:
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
validate_workflow_configs
|
|
71
|
+
|
|
72
|
+
supervisor.start
|
|
73
|
+
supervisor.restore_from_registry
|
|
74
|
+
supervisor.start_spawners
|
|
75
|
+
|
|
76
|
+
control_server.start
|
|
77
|
+
Superkick.logger.info("server") { "Control server listening on #{Superkick.config.socket_path}" }
|
|
78
|
+
|
|
79
|
+
wait_for_signals(control_server, supervisor)
|
|
80
|
+
rescue AlreadyRunning => e
|
|
81
|
+
warn "superkick: #{e.message}"
|
|
82
|
+
exit(1)
|
|
83
|
+
ensure
|
|
84
|
+
cleanup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
AlreadyRunning = Class.new(StandardError)
|
|
90
|
+
|
|
91
|
+
def check_already_running!
|
|
92
|
+
return unless File.exist?(Superkick.config.pid_path)
|
|
93
|
+
|
|
94
|
+
pid = File.read(Superkick.config.pid_path).strip.to_i
|
|
95
|
+
return if pid <= 0
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
Process.kill(0, pid)
|
|
99
|
+
raise AlreadyRunning, "Server already running (PID #{pid})"
|
|
100
|
+
rescue Errno::ESRCH
|
|
101
|
+
# Stale PID file — remove and continue
|
|
102
|
+
FileUtils.rm_f(Superkick.config.pid_path)
|
|
103
|
+
rescue Errno::EPERM
|
|
104
|
+
raise AlreadyRunning, "Server already running (PID #{pid})"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def write_pid_file
|
|
109
|
+
FileUtils.mkdir_p(File.dirname(Superkick.config.pid_path))
|
|
110
|
+
File.write(Superkick.config.pid_path, "#{Process.pid}\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def daemonize!
|
|
114
|
+
pid = fork
|
|
115
|
+
exit(0) if pid # parent exits
|
|
116
|
+
|
|
117
|
+
Process.setsid
|
|
118
|
+
|
|
119
|
+
pid = fork
|
|
120
|
+
exit(0) if pid # first child exits
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(Superkick.config.base_dir)
|
|
123
|
+
|
|
124
|
+
$stdin.reopen(File::NULL)
|
|
125
|
+
$stdout.reopen(Superkick.config.log_path, "a")
|
|
126
|
+
$stderr.reopen(Superkick.config.log_path, "a")
|
|
127
|
+
$stdout.sync = true
|
|
128
|
+
$stderr.sync = true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def wait_for_signals(control_server, supervisor)
|
|
132
|
+
signal_queue = Queue.new
|
|
133
|
+
%w[TERM INT].each { |sig| Signal.trap(sig) { signal_queue << sig } }
|
|
134
|
+
|
|
135
|
+
Superkick.logger.info("server") { "Superkick server ready" }
|
|
136
|
+
|
|
137
|
+
loop do
|
|
138
|
+
break unless signal_queue.empty?
|
|
139
|
+
sleep 0.5
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig = signal_queue.pop
|
|
143
|
+
Superkick.logger.info("server") { "Received #{sig} — shutting down" }
|
|
144
|
+
|
|
145
|
+
control_server.stop
|
|
146
|
+
supervisor.stop
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Probe persisted buffer connections to check if agent processes survived
|
|
150
|
+
# a server restart. Keep live connections; detach and clean up stale ones.
|
|
151
|
+
def recover_agents(store, buffer_client)
|
|
152
|
+
store.each do |agent|
|
|
153
|
+
path = agent.buffer_socket_path
|
|
154
|
+
next unless path
|
|
155
|
+
|
|
156
|
+
if buffer_client.probe(agent.id)
|
|
157
|
+
Superkick.logger.info("server") { "Recovered buffer connection for agent #{agent.id}" }
|
|
158
|
+
else
|
|
159
|
+
Superkick.logger.info("server") { "Stale buffer connection for agent #{agent.id} — detaching" }
|
|
160
|
+
agent.detach_path(:buffer_socket_path)
|
|
161
|
+
FileUtils.rm_f(path)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_workflow_configs
|
|
167
|
+
spawners = Superkick.config.spawners
|
|
168
|
+
return if spawners.empty?
|
|
169
|
+
|
|
170
|
+
cycles = Spawn::WorkflowValidator.validate(spawners)
|
|
171
|
+
cycles.each do |cycle|
|
|
172
|
+
names = cycle.map(&:to_s).join(" -> ")
|
|
173
|
+
involved = cycle.uniq
|
|
174
|
+
|
|
175
|
+
if involved.any? { |name| spawners.dig(name, :allow_cycles) }
|
|
176
|
+
Superkick.logger.warn("server") { "Workflow cycle detected (allowed): #{names}" }
|
|
177
|
+
else
|
|
178
|
+
Superkick.logger.error("server") { "Workflow cycle detected: #{names} — disabling involved spawners" }
|
|
179
|
+
involved.each { |name| spawners.delete(name) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return if spawners.empty?
|
|
184
|
+
|
|
185
|
+
# Require max_iterations on cyclic spawners — without a limit, cycles recurse forever
|
|
186
|
+
missing = Spawn::WorkflowValidator.cyclic_spawners_without_iteration_limit(spawners)
|
|
187
|
+
missing.each do |name|
|
|
188
|
+
Superkick.logger.error("server") {
|
|
189
|
+
"Spawner #{name} has allow_cycles: true but no max_iterations — " \
|
|
190
|
+
"disabling to prevent infinite recursion. Set max_iterations on the spawner."
|
|
191
|
+
}
|
|
192
|
+
spawners.delete(name)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_attach_relay_store
|
|
197
|
+
config = {
|
|
198
|
+
replay_buffer_size: Superkick.config.attach_replay_buffer_size,
|
|
199
|
+
max_connections: Superkick.config.attach_max_connections,
|
|
200
|
+
rw_idle_timeout: Superkick.config.attach_rw_idle_timeout
|
|
201
|
+
}
|
|
202
|
+
Hosted::Attach::RelayStore.new(config:)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def cleanup
|
|
206
|
+
FileUtils.rm_f(Superkick.config.pid_path)
|
|
207
|
+
FileUtils.rm_f(Superkick.config.socket_path)
|
|
208
|
+
Superkick.logger.info("server") { "Superkick server stopped" }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Records complete, timestamped agent sessions (input + output) in the
|
|
5
|
+
# asciicast v2 format. Recordings enable audit logging and visual playback
|
|
6
|
+
# via asciinema-player or `asciinema play`.
|
|
7
|
+
#
|
|
8
|
+
# Thread-safe — three PtyProxy threads (proxy_stdin, proxy_output,
|
|
9
|
+
# drain_inject_queue) write concurrently via Mutex.
|
|
10
|
+
#
|
|
11
|
+
# Lifecycle follows OutputLogger: create → start → record_* → close.
|
|
12
|
+
class SessionRecorder
|
|
13
|
+
MAX_SIZE = 100 * 1024 * 1024 # 100 MB default
|
|
14
|
+
|
|
15
|
+
def initialize(agent_id:, width: 120, height: 40, max_size: MAX_SIZE,
|
|
16
|
+
recordings_dir: Superkick.config.recordings_dir, store: nil)
|
|
17
|
+
@path = File.join(recordings_dir, "#{agent_id}.cast")
|
|
18
|
+
@agent_id = agent_id
|
|
19
|
+
@width = width
|
|
20
|
+
@height = height
|
|
21
|
+
@max_size = max_size
|
|
22
|
+
@store = store
|
|
23
|
+
@file = nil
|
|
24
|
+
@bytes_written = 0
|
|
25
|
+
@start_time = nil
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def start
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
31
|
+
@file = File.open(@path, "ab")
|
|
32
|
+
@file.sync = true
|
|
33
|
+
@bytes_written = @file.size
|
|
34
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
write_header
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_output(data)
|
|
40
|
+
record_event("o", data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def record_input(data)
|
|
44
|
+
record_event("i", data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resize(width, height)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
return unless @file
|
|
50
|
+
|
|
51
|
+
@width = width
|
|
52
|
+
@height = height
|
|
53
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
54
|
+
line = JSON.generate([elapsed.round(6), "r", "#{width}x#{height}"]) + "\n"
|
|
55
|
+
rotate! if @bytes_written + line.bytesize > @max_size
|
|
56
|
+
@file.write(line)
|
|
57
|
+
@bytes_written += line.bytesize
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def close
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@file&.close
|
|
64
|
+
@file = nil
|
|
65
|
+
end
|
|
66
|
+
@store&.upload(agent_id: @agent_id, path: @path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_reader :path
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def write_header
|
|
74
|
+
header = {
|
|
75
|
+
version: 2,
|
|
76
|
+
width: @width,
|
|
77
|
+
height: @height,
|
|
78
|
+
timestamp: Time.now.to_i,
|
|
79
|
+
env: {"TERM" => ENV.fetch("TERM", "xterm-256color")}
|
|
80
|
+
}
|
|
81
|
+
line = JSON.generate(header) + "\n"
|
|
82
|
+
@file.write(line)
|
|
83
|
+
@bytes_written += line.bytesize
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record_event(type, data)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
return unless @file
|
|
89
|
+
|
|
90
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
91
|
+
line = JSON.generate([elapsed.round(6), type, data.b.force_encoding("UTF-8")]) + "\n"
|
|
92
|
+
rotate! if @bytes_written + line.bytesize > @max_size
|
|
93
|
+
@file.write(line)
|
|
94
|
+
@bytes_written += line.bytesize
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rotate!
|
|
99
|
+
@file.close
|
|
100
|
+
rotated = "#{@path}.1"
|
|
101
|
+
FileUtils.rm_f(rotated)
|
|
102
|
+
File.rename(@path, rotated)
|
|
103
|
+
@file = File.open(@path, "ab")
|
|
104
|
+
@file.sync = true
|
|
105
|
+
@bytes_written = 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Abstract upload interface for completed recordings.
|
|
109
|
+
# Local mode uses Noop; hosted mode uses an HTTP implementation.
|
|
110
|
+
class Store
|
|
111
|
+
def upload(agent_id:, path:)
|
|
112
|
+
raise NotImplementedError, "#{self.class}#upload not implemented"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Local mode — recordings stay on disk, no upload.
|
|
117
|
+
class Store::Noop < Store
|
|
118
|
+
def upload(agent_id:, path:) = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Hosted mode — uploads the completed .cast file to the hosted server
|
|
122
|
+
# via multipart POST. Uses faraday-multipart to stream the file without
|
|
123
|
+
# loading it entirely into memory.
|
|
124
|
+
class Store::Http < Store
|
|
125
|
+
def initialize(server_url:, api_key:, connection: nil)
|
|
126
|
+
@server_url = server_url
|
|
127
|
+
@api_key = api_key
|
|
128
|
+
@connection = connection || build_connection
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def upload(agent_id:, path:)
|
|
132
|
+
return unless File.exist?(path)
|
|
133
|
+
|
|
134
|
+
payload = {
|
|
135
|
+
file: Faraday::Multipart::FilePart.new(path, "application/x-asciicast")
|
|
136
|
+
}
|
|
137
|
+
@connection.post("/api/v1/agents/#{agent_id}/recording", payload)
|
|
138
|
+
rescue => e
|
|
139
|
+
Superkick.logger.warn("session_recorder") { "Recording upload failed for #{agent_id}: #{e.message}" }
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def build_connection
|
|
146
|
+
Faraday.new(url: @server_url) do |f|
|
|
147
|
+
f.request :authorization, "Bearer", @api_key
|
|
148
|
+
f.request :multipart
|
|
149
|
+
f.adapter Faraday.default_adapter
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Setup — generates a well-documented config.yml from user selections.
|
|
5
|
+
#
|
|
6
|
+
# Encapsulates config generation logic separate from the Thor command
|
|
7
|
+
# so it can be tested independently.
|
|
8
|
+
class Setup
|
|
9
|
+
HEADER = <<~YAML
|
|
10
|
+
# Superkick configuration
|
|
11
|
+
# Generated by `superkick setup` on %<date>s
|
|
12
|
+
# Docs: https://github.com/admtnnr/superkick
|
|
13
|
+
YAML
|
|
14
|
+
|
|
15
|
+
SETTINGS_SECTION = <<~YAML
|
|
16
|
+
# ── Settings ─────────────────────────────────────────────
|
|
17
|
+
# Uncomment to customize. Defaults are shown.
|
|
18
|
+
# superkick:
|
|
19
|
+
# poll_interval: 30 # seconds between monitor ticks
|
|
20
|
+
# idle_threshold: 5.0 # seconds CLI must be idle before injection
|
|
21
|
+
# log_level: info # debug, info, warn, error
|
|
22
|
+
YAML
|
|
23
|
+
|
|
24
|
+
def initialize(base_dir: nil)
|
|
25
|
+
@base_dir = base_dir || Superkick.config.base_dir
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns Array of { name:, cli_command:, installed: } for each registered driver.
|
|
29
|
+
def detect_drivers
|
|
30
|
+
Driver.registered.map do |name, klass|
|
|
31
|
+
instance = klass.new
|
|
32
|
+
cli_command = instance.cli_command
|
|
33
|
+
installed = command_installed?(cli_command)
|
|
34
|
+
{name:, cli_command:, installed:}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns Array of { type:, label:, description: } for each monitor with setup_label.
|
|
39
|
+
def available_monitors
|
|
40
|
+
collect_available(Monitor.registered)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns Array of { type:, label:, description: } for each spawner with setup_label.
|
|
44
|
+
def available_spawners
|
|
45
|
+
collect_available(Spawner.registered)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns Array of { type:, label:, description: } for each notifier with setup_label.
|
|
49
|
+
def available_notifiers
|
|
50
|
+
collect_available(Notifier.registered)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns Array of { type:, label:, description: } for each repository source with setup_label.
|
|
54
|
+
def available_repository_sources
|
|
55
|
+
collect_available(RepositorySource.registered)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Assembles the final config.yml string from selected components.
|
|
59
|
+
def generate_config(driver:, monitors: [], spawners: [], notifiers: [], repository_sources: [])
|
|
60
|
+
parts = []
|
|
61
|
+
parts << format(HEADER, date: Date.today.iso8601)
|
|
62
|
+
parts << "driver: #{driver}\n"
|
|
63
|
+
|
|
64
|
+
append_monitors_section(parts, monitors)
|
|
65
|
+
append_spawners_section(parts, spawners)
|
|
66
|
+
append_notifications_section(parts, notifiers)
|
|
67
|
+
append_repositories_section(parts, repository_sources)
|
|
68
|
+
parts << SETTINGS_SECTION
|
|
69
|
+
|
|
70
|
+
parts.join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def collect_available(registry)
|
|
76
|
+
registry.filter_map do |type, klass|
|
|
77
|
+
label = klass.setup_label
|
|
78
|
+
next unless label
|
|
79
|
+
|
|
80
|
+
description = klass.respond_to?(:description) ? klass.description : nil
|
|
81
|
+
{type:, label:, description:}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def command_installed?(command)
|
|
86
|
+
system("which", command, out: File::NULL, err: File::NULL)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def append_monitors_section(parts, monitor_types)
|
|
90
|
+
return if monitor_types.empty?
|
|
91
|
+
|
|
92
|
+
snippets = monitor_types.filter_map { lookup_setup_config(Monitor, it) }
|
|
93
|
+
return if snippets.empty?
|
|
94
|
+
|
|
95
|
+
parts << <<~YAML
|
|
96
|
+
# ── Monitors ─────────────────────────────────────────────
|
|
97
|
+
# Monitors watch external services and inject context into your AI CLI.
|
|
98
|
+
# Config values like repo and branch are auto-detected at runtime.
|
|
99
|
+
monitors:
|
|
100
|
+
YAML
|
|
101
|
+
snippets.each { parts << indent(it, 2) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def append_spawners_section(parts, spawner_types)
|
|
105
|
+
return if spawner_types.empty?
|
|
106
|
+
|
|
107
|
+
snippets = spawner_types.filter_map { lookup_setup_config(Spawner, it) }
|
|
108
|
+
return if snippets.empty?
|
|
109
|
+
|
|
110
|
+
parts << <<~YAML
|
|
111
|
+
# ── Spawners ─────────────────────────────────────────────
|
|
112
|
+
# Spawners watch external services and spawn new agents automatically.
|
|
113
|
+
spawners:
|
|
114
|
+
YAML
|
|
115
|
+
snippets.each { parts << indent(it, 2) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def append_notifications_section(parts, notifier_types)
|
|
119
|
+
return if notifier_types.empty?
|
|
120
|
+
|
|
121
|
+
snippets = notifier_types.filter_map { lookup_setup_config(Notifier, it) }
|
|
122
|
+
return if snippets.empty?
|
|
123
|
+
|
|
124
|
+
parts << <<~YAML
|
|
125
|
+
# ── Notifications ────────────────────────────────────────
|
|
126
|
+
# Notifications fire after injections and agent lifecycle events.
|
|
127
|
+
notifications:
|
|
128
|
+
YAML
|
|
129
|
+
snippets.each { parts << indent(it, 2) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def append_repositories_section(parts, source_types)
|
|
133
|
+
return if source_types.empty?
|
|
134
|
+
|
|
135
|
+
snippets = source_types.filter_map { lookup_setup_config(RepositorySource, it) }
|
|
136
|
+
return if snippets.empty?
|
|
137
|
+
|
|
138
|
+
parts << <<~YAML
|
|
139
|
+
# ── Repositories ─────────────────────────────────────────
|
|
140
|
+
# Repository sources for team planning and spawned agent workspaces.
|
|
141
|
+
repositories:
|
|
142
|
+
YAML
|
|
143
|
+
snippets.each { parts << indent(it, 2) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def lookup_setup_config(registry_class, type)
|
|
147
|
+
klass = registry_class.lookup(type)
|
|
148
|
+
klass.setup_config
|
|
149
|
+
rescue ArgumentError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def indent(text, spaces)
|
|
154
|
+
prefix = " " * spaces
|
|
155
|
+
text.lines.map { |line|
|
|
156
|
+
line.strip.empty? ? "\n" : "#{prefix}#{line}"
|
|
157
|
+
}.join
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|