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,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pty"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Superkick
|
|
8
|
+
# PtyProxy wraps an AI coding CLI in a pseudo-terminal so Superkick controls
|
|
9
|
+
# the terminal directly. Three internal threads handle:
|
|
10
|
+
# 1. stdin proxy — reads raw keystrokes, feeds InputBuffer, forwards to pty
|
|
11
|
+
# 2. output proxy — reads CLI output, writes to user stdout, tracks idle state
|
|
12
|
+
# 3. inject queue — drains @inject_queue, writes bytes to pty master
|
|
13
|
+
#
|
|
14
|
+
# An InjectionQueue drains rendered prompts from the server, checking
|
|
15
|
+
# preconditions (idle state, guards) locally before writing to the PTY.
|
|
16
|
+
#
|
|
17
|
+
# On startup it creates a Buffer::Server and registers both the agent and the
|
|
18
|
+
# buffer socket with the server (if running). In hosted mode, it also opens
|
|
19
|
+
# a persistent WebSocket to the server for receiving injection commands.
|
|
20
|
+
class PtyProxy
|
|
21
|
+
attr_reader :agent_id
|
|
22
|
+
|
|
23
|
+
def initialize(command:, idle_threshold:, inject_clear_delay:, args: [], agent_id: nil,
|
|
24
|
+
headless: false, team_id: nil, role: nil, team_log: true)
|
|
25
|
+
@command = command
|
|
26
|
+
@args = args
|
|
27
|
+
agent_id ||= "agent-#{SecureRandom.hex(4)}"
|
|
28
|
+
@agent_id = agent_id.to_s
|
|
29
|
+
@headless = headless
|
|
30
|
+
@team_id = team_id
|
|
31
|
+
@role = role
|
|
32
|
+
@team_log = team_log
|
|
33
|
+
|
|
34
|
+
@input_buffer = InputBuffer.new
|
|
35
|
+
@inject_queue = Queue.new
|
|
36
|
+
@output_mutex = Mutex.new
|
|
37
|
+
@last_output_at = nil
|
|
38
|
+
@at_prompt = false
|
|
39
|
+
|
|
40
|
+
@control_client = Control.client_from
|
|
41
|
+
|
|
42
|
+
@injection_queue = InjectionQueue.new(
|
|
43
|
+
pty_proxy: self, idle_threshold:, inject_clear_delay:, control_client: @control_client
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@buffer_server = Buffer::Server.new(
|
|
47
|
+
agent_id: @agent_id,
|
|
48
|
+
input_buffer: @input_buffer,
|
|
49
|
+
pty_proxy: self,
|
|
50
|
+
injection_queue: @injection_queue
|
|
51
|
+
)
|
|
52
|
+
@buffer_bridge = nil
|
|
53
|
+
@attach_server = Attach::Server.new(
|
|
54
|
+
agent_id: @agent_id,
|
|
55
|
+
pty_proxy: self,
|
|
56
|
+
control_client: @control_client
|
|
57
|
+
)
|
|
58
|
+
@output_logger = OutputLogger.new(agent_id: @agent_id)
|
|
59
|
+
@session_recorder = if Superkick.config.session_recording_enabled
|
|
60
|
+
build_session_recorder
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Blocking — returns when the CLI process exits.
|
|
65
|
+
def run
|
|
66
|
+
@buffer_server.start
|
|
67
|
+
@injection_queue.start
|
|
68
|
+
@attach_server.start
|
|
69
|
+
@output_logger.start
|
|
70
|
+
@session_recorder&.start
|
|
71
|
+
auto_register
|
|
72
|
+
notify_server(:register_buffer)
|
|
73
|
+
notify_server(:register_attach)
|
|
74
|
+
start_buffer_bridge
|
|
75
|
+
start_attach_bridge
|
|
76
|
+
|
|
77
|
+
PTY.spawn({"SUPERKICK_AGENT_ID" => @agent_id}, @command, *@args) do |pty_out, pty_in, pid|
|
|
78
|
+
@pty_pid = pid
|
|
79
|
+
@pty_in = pty_in
|
|
80
|
+
|
|
81
|
+
unless @headless
|
|
82
|
+
# Match window size
|
|
83
|
+
rows, cols = begin
|
|
84
|
+
$stdout.winsize
|
|
85
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
86
|
+
[24, 80]
|
|
87
|
+
end
|
|
88
|
+
begin
|
|
89
|
+
pty_in.winsize = [rows, cols]
|
|
90
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Signal.trap("WINCH") do
|
|
95
|
+
r, c = begin
|
|
96
|
+
$stdout.winsize
|
|
97
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
98
|
+
[24, 80]
|
|
99
|
+
end
|
|
100
|
+
begin
|
|
101
|
+
pty_in.winsize = [r, c]
|
|
102
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
@session_recorder&.resize(c, r)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
threads = []
|
|
110
|
+
threads << Thread.new { proxy_stdin(pty_in) } unless @headless
|
|
111
|
+
threads << Thread.new { proxy_output(pty_out) }
|
|
112
|
+
threads << Thread.new { drain_inject_queue(pty_in) }
|
|
113
|
+
|
|
114
|
+
Process.wait(pid)
|
|
115
|
+
threads.each(&:kill)
|
|
116
|
+
end
|
|
117
|
+
rescue PTY::ChildExited
|
|
118
|
+
# Normal exit
|
|
119
|
+
ensure
|
|
120
|
+
@attach_bridge&.stop
|
|
121
|
+
@buffer_bridge&.stop
|
|
122
|
+
@injection_queue.stop
|
|
123
|
+
@attach_server.stop
|
|
124
|
+
@buffer_server.stop
|
|
125
|
+
@session_recorder&.close
|
|
126
|
+
@output_logger.close
|
|
127
|
+
notify_server(:unregister_attach)
|
|
128
|
+
notify_server(:unregister_buffer)
|
|
129
|
+
notify_server(:unregister)
|
|
130
|
+
@control_client.close
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Called by Buffer::Server on `idle_state` command, and by InjectionQueue.
|
|
134
|
+
def idle_state
|
|
135
|
+
@output_mutex.synchronize do
|
|
136
|
+
seconds_idle = @last_output_at ? (Time.now.to_f - @last_output_at) : nil
|
|
137
|
+
{seconds_idle:, at_prompt: @at_prompt}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Called by Buffer::Server on `inject` command.
|
|
142
|
+
def enqueue_inject(bytes)
|
|
143
|
+
@inject_queue << bytes
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Called by InjectionQueue to check if guards are clear.
|
|
147
|
+
def guards_clear?
|
|
148
|
+
@input_buffer.guards.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Called by InjectionQueue to read partial user input for restoration.
|
|
152
|
+
def partial_input
|
|
153
|
+
@input_buffer.contents
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Called by Attach::Server when a read-write client sends a resize frame.
|
|
157
|
+
def resize(rows, cols)
|
|
158
|
+
return unless @pty_in
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
@pty_in.winsize = [rows, cols]
|
|
162
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
@session_recorder&.resize(cols, rows)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# ── Thread bodies ───────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def proxy_stdin(pty_in)
|
|
173
|
+
$stdin.raw do
|
|
174
|
+
loop do
|
|
175
|
+
chunk = $stdin.readpartial(4096)
|
|
176
|
+
@input_buffer.append(chunk)
|
|
177
|
+
pty_in.write(chunk)
|
|
178
|
+
pty_in.flush
|
|
179
|
+
@session_recorder&.record_input(chunk)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
rescue IOError, Errno::EIO
|
|
183
|
+
# stdin closed
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def proxy_output(pty_out)
|
|
187
|
+
loop do
|
|
188
|
+
begin
|
|
189
|
+
data = pty_out.read_nonblock(4096)
|
|
190
|
+
rescue IO::WaitReadable
|
|
191
|
+
IO.select([pty_out])
|
|
192
|
+
retry
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
unless @headless
|
|
196
|
+
$stdout.write(data)
|
|
197
|
+
$stdout.flush
|
|
198
|
+
end
|
|
199
|
+
@output_logger.write(data)
|
|
200
|
+
@session_recorder&.record_output(data)
|
|
201
|
+
@attach_server.broadcast(data)
|
|
202
|
+
|
|
203
|
+
stripped = strip_ansi(data)
|
|
204
|
+
|
|
205
|
+
@output_mutex.synchronize do
|
|
206
|
+
@last_output_at = Time.now.to_f
|
|
207
|
+
@at_prompt = prompt_match?(stripped)
|
|
208
|
+
update_guards(stripped)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Cost extraction — runs outside the output mutex
|
|
212
|
+
extract_cost(stripped)
|
|
213
|
+
end
|
|
214
|
+
rescue IOError, Errno::EIO
|
|
215
|
+
# pty closed (CLI exited)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def drain_inject_queue(pty_in)
|
|
219
|
+
loop do
|
|
220
|
+
bytes = @inject_queue.pop
|
|
221
|
+
pty_in.write(bytes)
|
|
222
|
+
pty_in.flush
|
|
223
|
+
@session_recorder&.record_input(bytes)
|
|
224
|
+
end
|
|
225
|
+
rescue IOError, Errno::EIO
|
|
226
|
+
# pty closed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
ANSI_CSI = /\e\[[0-9;]*[A-Za-z]/
|
|
232
|
+
ANSI_OSC = /\e\][^\a]*(?:\a|\e\\)/
|
|
233
|
+
ANSI_MISC = /\e./
|
|
234
|
+
|
|
235
|
+
def strip_ansi(text)
|
|
236
|
+
text.gsub(ANSI_CSI, "").gsub(ANSI_OSC, "").gsub(ANSI_MISC, "")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def prompt_match?(stripped_text)
|
|
240
|
+
return false unless Superkick.driver
|
|
241
|
+
|
|
242
|
+
patterns = Superkick.driver.prompt_patterns
|
|
243
|
+
return false if patterns.empty?
|
|
244
|
+
|
|
245
|
+
patterns.any? { it.match?(stripped_text) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def update_guards(stripped_text)
|
|
249
|
+
return unless Superkick.driver
|
|
250
|
+
|
|
251
|
+
Superkick.driver.injection_guards.each do |guard|
|
|
252
|
+
if guard.match?(stripped_text)
|
|
253
|
+
@input_buffer.set_guard(guard.name, guard.reason,
|
|
254
|
+
clear_on_submit: guard.clear_on_submit)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def extract_cost(stripped_text)
|
|
260
|
+
return unless Superkick.driver
|
|
261
|
+
|
|
262
|
+
patterns = Superkick.driver.cost_patterns
|
|
263
|
+
return if patterns.empty?
|
|
264
|
+
|
|
265
|
+
@cost_extractor ||= CostExtractor.new(patterns:)
|
|
266
|
+
deltas = @cost_extractor.feed(stripped_text)
|
|
267
|
+
deltas.each { report_cost(it) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def report_cost(data)
|
|
271
|
+
@control_client.request("report_cost",
|
|
272
|
+
agent_id: @agent_id,
|
|
273
|
+
**data,
|
|
274
|
+
source: :pty_scrape)
|
|
275
|
+
rescue Control::Client::ServerUnavailable
|
|
276
|
+
# Cost tracking is best-effort
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def notify_server(command, **extra)
|
|
280
|
+
extras = extra
|
|
281
|
+
if command == :register_buffer
|
|
282
|
+
extras[:output_log_path] = @output_logger.path
|
|
283
|
+
extras[:recording_path] = @session_recorder.path if @session_recorder
|
|
284
|
+
end
|
|
285
|
+
@control_client.request(command.to_s, agent_id: @agent_id, **extras)
|
|
286
|
+
rescue Control::Client::ServerUnavailable
|
|
287
|
+
# Server not required for `superkick agent` to work
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def auto_register
|
|
291
|
+
params = {agent_id: @agent_id, working_dir: Dir.pwd}
|
|
292
|
+
params[:team_id] = @team_id if @team_id
|
|
293
|
+
params[:role] = @role if @role
|
|
294
|
+
params[:team_log] = @team_log
|
|
295
|
+
result = @control_client.request("register", **params)
|
|
296
|
+
|
|
297
|
+
# Multi-step handshake: server responds with environment_request
|
|
298
|
+
if result.success? && result[:environment_request]
|
|
299
|
+
executor = EnvironmentExecutor.new(working_dir: Dir.pwd)
|
|
300
|
+
environment = executor.execute(result[:environment_request])
|
|
301
|
+
@control_client.request("register_environment",
|
|
302
|
+
agent_id: @agent_id, environment:)
|
|
303
|
+
end
|
|
304
|
+
rescue Control::Client::ServerUnavailable
|
|
305
|
+
Superkick.logger.info("pty:#{@agent_id}") { "Server not running — agent not registered" }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def build_session_recorder
|
|
309
|
+
store = if Superkick.config.server_type == :hosted
|
|
310
|
+
server_config = Superkick.config.server
|
|
311
|
+
SessionRecorder::Store::Http.new(
|
|
312
|
+
server_url: server_config[:url],
|
|
313
|
+
api_key: server_config[:api_key]
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
rows, cols = begin
|
|
318
|
+
$stdout.winsize
|
|
319
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
320
|
+
[40, 120]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
SessionRecorder.new(
|
|
324
|
+
agent_id: @agent_id,
|
|
325
|
+
width: cols,
|
|
326
|
+
height: rows,
|
|
327
|
+
max_size: Superkick.config.session_recording_max_size,
|
|
328
|
+
store:
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# In hosted mode, start the bridge that connects the local Buffer::Server
|
|
333
|
+
# to the hosted server over a persistent WebSocket. Buffer commands
|
|
334
|
+
# (enqueue_injection, idle_state, etc.) flow through this bridge. The local
|
|
335
|
+
# Buffer::Server still runs for local-mode compatibility.
|
|
336
|
+
def start_buffer_bridge
|
|
337
|
+
return unless Superkick.config.server_type == :hosted
|
|
338
|
+
|
|
339
|
+
server_config = Superkick.config.server
|
|
340
|
+
@buffer_bridge = Hosted::Buffer::Bridge.new(
|
|
341
|
+
agent_id: @agent_id,
|
|
342
|
+
server_url: server_config[:url],
|
|
343
|
+
api_key: server_config[:api_key],
|
|
344
|
+
command_handler: method(:handle_buffer_command)
|
|
345
|
+
)
|
|
346
|
+
@buffer_bridge.start
|
|
347
|
+
Superkick.logger.info("pty:#{@agent_id}") { "Buffer bridge started" }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# In hosted mode, start the bridge that connects the local Attach::Server
|
|
351
|
+
# to the hosted server over a persistent WebSocket. PTY output is
|
|
352
|
+
# forwarded to the relay; remote user input flows back.
|
|
353
|
+
def start_attach_bridge
|
|
354
|
+
return unless Superkick.config.server_type == :hosted
|
|
355
|
+
|
|
356
|
+
server_config = Superkick.config.server
|
|
357
|
+
@attach_bridge = Hosted::Attach::Bridge.new(
|
|
358
|
+
agent_id: @agent_id,
|
|
359
|
+
server_url: server_config[:url],
|
|
360
|
+
api_key: server_config[:api_key],
|
|
361
|
+
attach_server: @attach_server
|
|
362
|
+
)
|
|
363
|
+
@attach_bridge.start
|
|
364
|
+
Superkick.logger.info("pty:#{@agent_id}") { "Attach bridge started" }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Dispatch a buffer command received over the bridge.
|
|
368
|
+
# Returns a response hash to send back to the server.
|
|
369
|
+
def handle_buffer_command(message)
|
|
370
|
+
case message[:command]
|
|
371
|
+
when "enqueue_injection"
|
|
372
|
+
@injection_queue.enqueue(
|
|
373
|
+
id: message[:id],
|
|
374
|
+
prompt: message[:prompt],
|
|
375
|
+
monitor_type: message[:monitor_type],
|
|
376
|
+
monitor_name: message[:monitor_name],
|
|
377
|
+
priority: message[:priority]&.to_sym || :normal,
|
|
378
|
+
ttl: message[:ttl] || InjectionQueue::DEFAULT_TTL,
|
|
379
|
+
supersede_key: message[:supersede_key]
|
|
380
|
+
)
|
|
381
|
+
{ok: true, status: "queued"}
|
|
382
|
+
when "idle_state"
|
|
383
|
+
idle_state.merge(ok: true)
|
|
384
|
+
when "guards_active"
|
|
385
|
+
guards = @input_buffer.guards
|
|
386
|
+
{ok: true, active: !guards.empty?, guards: guards}
|
|
387
|
+
when "get"
|
|
388
|
+
contents = @input_buffer.contents
|
|
389
|
+
{ok: true, contents: contents.empty? ? nil : contents}
|
|
390
|
+
when "inject"
|
|
391
|
+
bytes = Base64.decode64(message[:data].to_s)
|
|
392
|
+
enqueue_inject(bytes)
|
|
393
|
+
{ok: true}
|
|
394
|
+
when "ping"
|
|
395
|
+
{ok: true}
|
|
396
|
+
else
|
|
397
|
+
{ok: false, error: "unknown command: #{message[:command]}"}
|
|
398
|
+
end
|
|
399
|
+
rescue => e
|
|
400
|
+
{ok: false, error: e.message}
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Registry — shared test isolation for class-level plugin registries.
|
|
5
|
+
#
|
|
6
|
+
# Include this module in the singleton class of any registry host that
|
|
7
|
+
# stores plugins in `@registry`. It provides `with_registry` for
|
|
8
|
+
# snapshot/restore scoping in tests, replacing ad-hoc deregister/restore
|
|
9
|
+
# patterns.
|
|
10
|
+
#
|
|
11
|
+
# Usage in a registry host:
|
|
12
|
+
#
|
|
13
|
+
# class MyPlugin
|
|
14
|
+
# @registry = {}
|
|
15
|
+
# class << self
|
|
16
|
+
# include Superkick::Registry
|
|
17
|
+
# # ... register, lookup, registered, etc.
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Block-scoped usage in tests (preferred):
|
|
22
|
+
#
|
|
23
|
+
# MyPlugin.with_registry do
|
|
24
|
+
# MyPlugin.register(StubPlugin)
|
|
25
|
+
# # StubPlugin visible here, cleaned up automatically
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Optionally pre-seed the isolated registry:
|
|
29
|
+
#
|
|
30
|
+
# MyPlugin.with_registry(stub: StubPlugin) do
|
|
31
|
+
# assert_equal StubPlugin, MyPlugin.lookup(:stub)
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# Paired save/restore for Minitest before/after hooks:
|
|
35
|
+
#
|
|
36
|
+
# before do
|
|
37
|
+
# @saved = MyPlugin.snapshot_registry
|
|
38
|
+
# MyPlugin.register(StubPlugin)
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# after do
|
|
42
|
+
# MyPlugin.restore_registry(@saved)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
module Registry
|
|
46
|
+
# Snapshot the current registry, optionally replace it, yield, then
|
|
47
|
+
# restore. Thread-safe per block scope (the snapshot is local).
|
|
48
|
+
#
|
|
49
|
+
# @param entries [Hash] optional entries to seed the isolated registry with
|
|
50
|
+
# @yield block runs with the isolated registry
|
|
51
|
+
# @return the block's return value
|
|
52
|
+
def with_registry(**entries, &block)
|
|
53
|
+
saved = @registry.dup
|
|
54
|
+
@registry = entries.any? ? entries.dup : saved.dup
|
|
55
|
+
block.call
|
|
56
|
+
ensure
|
|
57
|
+
@registry = saved
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Take a snapshot of the current registry for later restoration.
|
|
61
|
+
# Use with `restore_registry` in Minitest before/after hooks.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] frozen snapshot of the registry
|
|
64
|
+
def snapshot_registry
|
|
65
|
+
@registry.dup.freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Restore the registry from a previous snapshot.
|
|
69
|
+
#
|
|
70
|
+
# @param snapshot [Hash] a snapshot from `snapshot_registry`
|
|
71
|
+
def restore_registry(snapshot)
|
|
72
|
+
@registry = snapshot.dup
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Repository — metadata for a single repository in the catalog.
|
|
5
|
+
class Repository
|
|
6
|
+
attr_reader :name, :path, :url, :dependencies,
|
|
7
|
+
:version_control, :context_documents
|
|
8
|
+
|
|
9
|
+
# Well-known context document patterns — glob relative to repo root.
|
|
10
|
+
CONTEXT_DOCUMENT_PATTERNS = {
|
|
11
|
+
readme: "README*",
|
|
12
|
+
claude_md: "CLAUDE.md",
|
|
13
|
+
agents_md: "AGENTS.md",
|
|
14
|
+
contributing: "CONTRIBUTING*",
|
|
15
|
+
conventions: "CONVENTIONS*",
|
|
16
|
+
cursorrules: ".cursorrules"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(name:, path: nil, url: nil, dependencies: [],
|
|
20
|
+
version_control: nil, context_documents: {})
|
|
21
|
+
@name = name.to_sym
|
|
22
|
+
@path = path && File.expand_path(path)
|
|
23
|
+
@url = url
|
|
24
|
+
@dependencies = dependencies.map(&:to_s)
|
|
25
|
+
@context_documents = (context_documents || {}).dup
|
|
26
|
+
@version_control = version_control&.to_sym
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Convenience accessor — returns the :readme context document content.
|
|
30
|
+
def readme_content
|
|
31
|
+
@context_documents[:readme]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convenience writer — sets the :readme context document.
|
|
35
|
+
# Used by OrganizationRepositorySource to pre-populate fetched content.
|
|
36
|
+
def readme_content=(content)
|
|
37
|
+
@context_documents[:readme] = content
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Set a named context document. Used by sources to populate documents
|
|
41
|
+
# after construction.
|
|
42
|
+
def set_context_document(name, content)
|
|
43
|
+
@context_documents[name.to_sym] = content
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
h = {name: @name}
|
|
48
|
+
h[:path] = @path if @path
|
|
49
|
+
h[:url] = @url if @url
|
|
50
|
+
h[:version_control] = @version_control if @version_control
|
|
51
|
+
h[:dependencies] = @dependencies if @dependencies.any?
|
|
52
|
+
h[:context_documents] = @context_documents.keys if @context_documents.any?
|
|
53
|
+
h
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# RepositorySource — abstract base class for repository sources.
|
|
58
|
+
#
|
|
59
|
+
# A repository source knows how to discover and describe repositories.
|
|
60
|
+
# It provides metadata (name, path, url, etc.) but does not handle
|
|
61
|
+
# workspace preparation — that's the job of VersionControl adapters.
|
|
62
|
+
#
|
|
63
|
+
# Subclass contract:
|
|
64
|
+
# self.type → unique Symbol (e.g. :local, :github_organization)
|
|
65
|
+
# repositories → Hash of { name_sym => Repository }
|
|
66
|
+
# find_by_name(n) → Repository or nil
|
|
67
|
+
# to_prompt_context → String for planning agent prompt
|
|
68
|
+
# empty? / size → catalog stats
|
|
69
|
+
#
|
|
70
|
+
# Built-in implementations:
|
|
71
|
+
# Local::RepositorySource — scans a local directory
|
|
72
|
+
# Integrations::Git::RepositorySource — single URL-based git repo
|
|
73
|
+
# Integrations::GitHub::OrganizationRepositorySource — lists repos from a GitHub org
|
|
74
|
+
# CompositeRepositorySource — unions multiple sources
|
|
75
|
+
class RepositorySource
|
|
76
|
+
# ── Registry (stores classes, keyed by type) ─────────────────────────
|
|
77
|
+
@registry = {}
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
include Superkick::Registry
|
|
81
|
+
|
|
82
|
+
def register(source_class)
|
|
83
|
+
key = source_class.type
|
|
84
|
+
raise ArgumentError, "RepositorySource :#{key} already registered" if @registry.key?(key)
|
|
85
|
+
@registry[key] = source_class
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def lookup(name)
|
|
89
|
+
@registry[name.to_sym] or raise ArgumentError, "Unknown repository source type: #{name.inspect}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def registered
|
|
93
|
+
@registry.dup.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Build a source from config.
|
|
97
|
+
#
|
|
98
|
+
# Accepts a Hash where each key is a user-chosen name and each value is either:
|
|
99
|
+
# - a typed source config (has :type key) → built as its own typed source
|
|
100
|
+
# - an untyped entry with :path → wrapped as a Local::RepositorySource (depth 0)
|
|
101
|
+
#
|
|
102
|
+
# Entries without :type and without :path are silently skipped.
|
|
103
|
+
#
|
|
104
|
+
# When the result is a single source, returns it directly.
|
|
105
|
+
# When there are multiple, composites them.
|
|
106
|
+
def build(config)
|
|
107
|
+
config = {} if config.nil?
|
|
108
|
+
return Local::RepositorySource.new({}) if config.empty?
|
|
109
|
+
|
|
110
|
+
sources = []
|
|
111
|
+
|
|
112
|
+
config.each do |name, entry_config|
|
|
113
|
+
next unless entry_config.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
if entry_config.key?(:type)
|
|
116
|
+
klass = lookup(entry_config[:type].to_sym)
|
|
117
|
+
sources << klass.new(entry_config.merge(name:))
|
|
118
|
+
elsif entry_config[:path]
|
|
119
|
+
sources << Local::RepositorySource.new(path: entry_config[:path], depth: 0, name:)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
case sources.size
|
|
124
|
+
when 0 then Local::RepositorySource.new({})
|
|
125
|
+
when 1 then sources.first
|
|
126
|
+
else CompositeRepositorySource.new(sources)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.type
|
|
132
|
+
raise NotImplementedError, "#{self}.type not implemented"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Setup wizard metadata. Override in subclasses that should appear in `superkick setup`.
|
|
136
|
+
def self.setup_label = nil
|
|
137
|
+
def self.setup_config = nil
|
|
138
|
+
|
|
139
|
+
def repositories
|
|
140
|
+
raise NotImplementedError, "#{self.class}#repositories not implemented"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_by_name(name)
|
|
144
|
+
repositories[name.to_sym]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_prompt_context
|
|
148
|
+
return "No repositories configured." if repositories.empty?
|
|
149
|
+
|
|
150
|
+
repositories.map do |name, repository|
|
|
151
|
+
lines = ["### #{name}"]
|
|
152
|
+
lines << "Dependencies: #{repository.dependencies.join(", ")}" if repository.dependencies.any?
|
|
153
|
+
repository.context_documents.each do |doc_name, content|
|
|
154
|
+
excerpt = content.lines.first(30).join
|
|
155
|
+
lines << ""
|
|
156
|
+
lines << "#{doc_name.to_s.tr("_", " ").capitalize} (excerpt):"
|
|
157
|
+
lines << excerpt
|
|
158
|
+
end
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end.join("\n\n")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def empty?
|
|
164
|
+
repositories.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def size
|
|
168
|
+
repositories.size
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# CompositeRepositorySource — unions multiple sources into one.
|
|
173
|
+
#
|
|
174
|
+
# Built automatically by RepositorySource.build when the config contains
|
|
175
|
+
# multiple sources. Earlier entries win on name collisions
|
|
176
|
+
# (first-found precedence).
|
|
177
|
+
class CompositeRepositorySource < RepositorySource
|
|
178
|
+
def self.type = :composite
|
|
179
|
+
|
|
180
|
+
def initialize(children = [])
|
|
181
|
+
@children = children
|
|
182
|
+
@repositories = children
|
|
183
|
+
.each_with_object({}) do |child, merged|
|
|
184
|
+
child.repositories.each do |name, repository|
|
|
185
|
+
merged[name] ||= repository
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
.freeze
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
attr_reader :repositories, :children
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
RepositorySource.register(CompositeRepositorySource)
|
|
195
|
+
end
|