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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Buffer
|
|
6
|
+
# WebSocket client for sending buffer commands to remote agents.
|
|
7
|
+
#
|
|
8
|
+
# In hosted mode, agents maintain persistent WebSocket connections to the
|
|
9
|
+
# server. The server holds a Relay per agent. This client sends
|
|
10
|
+
# commands through those relays.
|
|
11
|
+
#
|
|
12
|
+
# This is a server-side component — it routes commands to agents via
|
|
13
|
+
# their WebSocket connections, not over Unix sockets.
|
|
14
|
+
class Client < Superkick::Buffer::Client
|
|
15
|
+
def self.from(relay_store:, **_kwargs)
|
|
16
|
+
new(relay_store:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(relay_store:)
|
|
20
|
+
@relay_store = relay_store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Send a command to the agent via its WebSocket relay and return the response.
|
|
24
|
+
def request(agent_id, command, **params)
|
|
25
|
+
relay = resolve_relay(agent_id)
|
|
26
|
+
relay.request(command, **params)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Fire-and-forget: send a command through the relay without waiting
|
|
30
|
+
# for a response. Overrides the base class to use the relay's
|
|
31
|
+
# fire-and-forget method instead of the request/response path.
|
|
32
|
+
def send_command(agent_id, command, **params)
|
|
33
|
+
relay = resolve_relay(agent_id)
|
|
34
|
+
relay.send_command(command, **params)
|
|
35
|
+
rescue AgentUnreachable => e
|
|
36
|
+
Superkick.logger.debug(log_tag) { "Send failed for #{agent_id}: #{e.message}" }
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if the agent has an active WebSocket relay connection.
|
|
41
|
+
def reachable?(agent_id)
|
|
42
|
+
relay = @relay_store&.get(agent_id)
|
|
43
|
+
relay&.connected? || false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def log_tag
|
|
49
|
+
"buffer:hosted"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_relay(agent_id)
|
|
53
|
+
relay = @relay_store&.get(agent_id)
|
|
54
|
+
raise AgentUnreachable, "No relay for agent #{agent_id}" unless relay&.connected?
|
|
55
|
+
|
|
56
|
+
relay
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Superkick::Buffer.register_client(:hosted, Superkick::Hosted::Buffer::Client)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Hosted
|
|
7
|
+
module Buffer
|
|
8
|
+
# Server-side relay that bridges the Injector/Supervisor to a remote agent
|
|
9
|
+
# over a persistent raw WebSocket connection (not ActionCable).
|
|
10
|
+
#
|
|
11
|
+
# In hosted mode, the agent opens a raw WebSocket to the server and
|
|
12
|
+
# authenticates via the shared auth protocol (see Hosted::Bridge). The
|
|
13
|
+
# server creates a Relay for each connected agent. Server-side components
|
|
14
|
+
# send buffer commands through the Relay, which forwards them as JSON
|
|
15
|
+
# text frames and (for request/response commands) waits for the reply.
|
|
16
|
+
#
|
|
17
|
+
# The Relay is transport-agnostic — it sends/receives JSON hashes through
|
|
18
|
+
# a websocket object that responds to #send(String).
|
|
19
|
+
class Relay
|
|
20
|
+
RESPONSE_TIMEOUT = 5 # seconds
|
|
21
|
+
|
|
22
|
+
def initialize(agent_id:)
|
|
23
|
+
@agent_id = agent_id
|
|
24
|
+
@websocket = nil
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@pending = {}
|
|
27
|
+
@pending_mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Called by the WebSocket handler when an agent connects.
|
|
31
|
+
def attach(websocket)
|
|
32
|
+
@mutex.synchronize { @websocket = websocket }
|
|
33
|
+
Superkick.logger.info("buffer:relay") { "Agent #{@agent_id} attached" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Called when the WebSocket connection closes.
|
|
37
|
+
def detach
|
|
38
|
+
@mutex.synchronize { @websocket = nil }
|
|
39
|
+
|
|
40
|
+
# Wake up any pending request/response waiters
|
|
41
|
+
@pending_mutex.synchronize do
|
|
42
|
+
@pending.each_value { it[:condition]&.broadcast }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Superkick.logger.info("buffer:relay") { "Agent #{@agent_id} detached" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connected?
|
|
49
|
+
@mutex.synchronize { !@websocket.nil? }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Send a command and wait for the response (request/response pattern).
|
|
53
|
+
def request(command, **params)
|
|
54
|
+
ws = @mutex.synchronize { @websocket }
|
|
55
|
+
raise Superkick::Buffer::Client::AgentUnreachable, "Agent #{@agent_id} not connected" unless ws
|
|
56
|
+
|
|
57
|
+
request_id = SecureRandom.uuid
|
|
58
|
+
message = {command:, request_id:, **params}
|
|
59
|
+
|
|
60
|
+
# Register a pending response slot
|
|
61
|
+
entry = {response: nil, condition: ConditionVariable.new, mutex: Mutex.new}
|
|
62
|
+
@pending_mutex.synchronize { @pending[request_id] = entry }
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
ws.send(JSON.generate(message))
|
|
66
|
+
|
|
67
|
+
# Wait for the response with timeout
|
|
68
|
+
entry[:mutex].synchronize do
|
|
69
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + RESPONSE_TIMEOUT
|
|
70
|
+
while entry[:response].nil?
|
|
71
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
break if remaining <= 0
|
|
73
|
+
entry[:condition].wait(entry[:mutex], remaining)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
entry[:response] || raise(Superkick::Buffer::Client::AgentUnreachable, "Response timeout for #{@agent_id}")
|
|
78
|
+
ensure
|
|
79
|
+
@pending_mutex.synchronize { @pending.delete(request_id) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fire-and-forget: send a command without waiting for a response.
|
|
84
|
+
def send_command(command, **params)
|
|
85
|
+
ws = @mutex.synchronize { @websocket }
|
|
86
|
+
raise Superkick::Buffer::Client::AgentUnreachable, "Agent #{@agent_id} not connected" unless ws
|
|
87
|
+
|
|
88
|
+
message = {command:, **params}
|
|
89
|
+
ws.send(JSON.generate(message))
|
|
90
|
+
{ok: true}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Called when the agent sends a message back over the WebSocket.
|
|
94
|
+
# Routes responses to pending request/response waiters, and handles
|
|
95
|
+
# unsolicited messages (e.g. injection_result IPC).
|
|
96
|
+
def handle_message(message)
|
|
97
|
+
request_id = message[:request_id]
|
|
98
|
+
|
|
99
|
+
if request_id
|
|
100
|
+
entry = @pending_mutex.synchronize { @pending[request_id] }
|
|
101
|
+
if entry
|
|
102
|
+
entry[:mutex].synchronize do
|
|
103
|
+
entry[:response] = message
|
|
104
|
+
entry[:condition].broadcast
|
|
105
|
+
end
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Unsolicited message from agent (e.g. injection_result)
|
|
111
|
+
handle_agent_event(message)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Handle unsolicited agent-to-server messages.
|
|
117
|
+
# In hosted mode, injection_result and report_cost are sent back over
|
|
118
|
+
# the WebSocket rather than opening separate control connections.
|
|
119
|
+
def handle_agent_event(message)
|
|
120
|
+
Superkick.logger.debug("buffer:relay") { "Agent event from #{@agent_id}: #{message[:command]}" }
|
|
121
|
+
# Future: route to control server command handlers
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Buffer
|
|
6
|
+
# Thread-safe registry of Hosted::Buffer::Relay instances, one per connected agent.
|
|
7
|
+
# Created at server startup in hosted mode and passed to Buffer::Client.
|
|
8
|
+
class RelayStore
|
|
9
|
+
def initialize
|
|
10
|
+
@relays = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get or create a relay for the given agent.
|
|
15
|
+
def get_or_create(agent_id)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@relays[agent_id] ||= Relay.new(agent_id:)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get an existing relay (nil if none).
|
|
22
|
+
def get(agent_id)
|
|
23
|
+
@mutex.synchronize { @relays[agent_id] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Remove a relay when the agent disconnects permanently.
|
|
27
|
+
def remove(agent_id)
|
|
28
|
+
@mutex.synchronize { @relays.delete(agent_id) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Iterate over all relays.
|
|
32
|
+
def each(&block)
|
|
33
|
+
@mutex.synchronize { @relays.each_value(&block) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@mutex.synchronize { @relays.size }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Hosted
|
|
7
|
+
module Control
|
|
8
|
+
# HTTPS client for communicating with a remote Superkick control server.
|
|
9
|
+
# Implements the same interface as Superkick::Control::Client so callers
|
|
10
|
+
# are transport-agnostic.
|
|
11
|
+
#
|
|
12
|
+
# All control commands go through POST /api/v1/control with the command
|
|
13
|
+
# name in the JSON body — no per-command HTTP routes.
|
|
14
|
+
class Client < Superkick::Control::Client
|
|
15
|
+
TIMEOUT = 10 # seconds
|
|
16
|
+
|
|
17
|
+
def self.from(config: Superkick.config, connection: nil, **_kwargs)
|
|
18
|
+
server = config.server
|
|
19
|
+
new(
|
|
20
|
+
url: server[:url],
|
|
21
|
+
api_key: server[:api_key],
|
|
22
|
+
connection:
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param url [String] base URL of the hosted server
|
|
27
|
+
# @param api_key [String] bearer token for authentication
|
|
28
|
+
# @param connection [Faraday::Connection, nil] optional pre-built connection (for testing)
|
|
29
|
+
def initialize(url:, api_key:, connection: nil)
|
|
30
|
+
raise ArgumentError, "url is required for hosted transport" unless url
|
|
31
|
+
raise ArgumentError, "api_key is required for hosted transport" unless api_key
|
|
32
|
+
|
|
33
|
+
@url = url.chomp("/")
|
|
34
|
+
@api_key = api_key
|
|
35
|
+
@connection = connection || build_connection
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Send a command to the hosted server and return the wrapped response.
|
|
39
|
+
# @param command [String] control command name
|
|
40
|
+
# @param params [Hash] extra key/value params merged into the request
|
|
41
|
+
# @return [Control::Reply] wrapped response from the server
|
|
42
|
+
def request(command, **params)
|
|
43
|
+
response = @connection.post("/api/v1/control", {command:, **params})
|
|
44
|
+
|
|
45
|
+
case response.status
|
|
46
|
+
when 200 then Superkick::Control::Reply.new(response.body)
|
|
47
|
+
when 401 then raise AuthenticationError, "Authentication failed — check your API key"
|
|
48
|
+
else raise ServerUnavailable, "Server returned HTTP #{response.status}"
|
|
49
|
+
end
|
|
50
|
+
rescue Faraday::ConnectionFailed => e
|
|
51
|
+
raise ServerUnavailable, "Connection failed: #{e.message}"
|
|
52
|
+
rescue Faraday::TimeoutError => e
|
|
53
|
+
raise ServerUnavailable, "Request timed out: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns true if the hosted server is reachable and authenticated.
|
|
57
|
+
def alive?
|
|
58
|
+
request("ping").success?
|
|
59
|
+
rescue ServerUnavailable
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Close the underlying Faraday connection.
|
|
64
|
+
def close
|
|
65
|
+
@connection.close
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def build_connection
|
|
71
|
+
Faraday.new(url: @url) do |f|
|
|
72
|
+
f.request :authorization, "Bearer", @api_key
|
|
73
|
+
f.request :json
|
|
74
|
+
f.response :json, parser_options: {symbolize_names: true}
|
|
75
|
+
f.options.timeout = TIMEOUT
|
|
76
|
+
f.options.open_timeout = TIMEOUT
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Superkick::Control.register_client(:hosted, Superkick::Hosted::Control::Client)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Hosted
|
|
8
|
+
# Transparent MCP stdio-to-HTTP proxy for hosted mode.
|
|
9
|
+
#
|
|
10
|
+
# Runs as a local stdio subprocess (same as McpServer) but instead of
|
|
11
|
+
# handling tool calls locally, forwards all JSON-RPC messages to the
|
|
12
|
+
# hosted server's Streamable HTTP MCP endpoint. Stamps the agent identity
|
|
13
|
+
# header on every request so the hosted server knows which agent is calling.
|
|
14
|
+
#
|
|
15
|
+
# The AI CLI sees a normal stdio MCP server — it has no idea it's talking
|
|
16
|
+
# to a remote endpoint. This avoids teaching every driver about remote MCP
|
|
17
|
+
# URL configuration.
|
|
18
|
+
#
|
|
19
|
+
# Protocol flow:
|
|
20
|
+
# AI CLI → stdin (JSON-RPC) → McpProxy → POST /api/v1/mcp → Hosted Server
|
|
21
|
+
# Hosted Server → HTTP response → McpProxy → stdout (JSON-RPC) → AI CLI
|
|
22
|
+
#
|
|
23
|
+
# Streamable HTTP responses may be:
|
|
24
|
+
# - application/json — single JSON-RPC response (written directly to stdout)
|
|
25
|
+
# - text/event-stream — SSE stream of JSON-RPC messages (each event written to stdout)
|
|
26
|
+
# - 202 Accepted — notification acknowledged, no response body
|
|
27
|
+
class McpProxy
|
|
28
|
+
TIMEOUT = 30 # seconds — tool calls may take a while
|
|
29
|
+
|
|
30
|
+
def self.start
|
|
31
|
+
new.run
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param connection [Faraday::Connection, nil] optional pre-built connection (for testing)
|
|
35
|
+
def initialize(connection: nil)
|
|
36
|
+
@agent_id = ENV["SUPERKICK_AGENT_ID"]
|
|
37
|
+
raise "SUPERKICK_AGENT_ID must be set. The MCP proxy should be started via `superkick agent`." unless @agent_id
|
|
38
|
+
|
|
39
|
+
server_config = Superkick.config.server
|
|
40
|
+
@url = server_config[:url]
|
|
41
|
+
@api_key = server_config[:api_key]
|
|
42
|
+
raise "server.url is required for hosted MCP proxy" unless @url
|
|
43
|
+
raise "server.api_key is required for hosted MCP proxy" unless @api_key
|
|
44
|
+
|
|
45
|
+
@url = @url.chomp("/")
|
|
46
|
+
@connection = connection || build_connection
|
|
47
|
+
@session_id = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
$stdin.each_line do |line|
|
|
52
|
+
line = line.strip
|
|
53
|
+
next if line.empty?
|
|
54
|
+
|
|
55
|
+
forward(line)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def forward(json_body)
|
|
62
|
+
headers = {"Content-Type" => "application/json", "Accept" => "application/json, text/event-stream"}
|
|
63
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
|
64
|
+
|
|
65
|
+
response = @connection.post("/api/v1/mcp", json_body, headers)
|
|
66
|
+
|
|
67
|
+
# Track session ID from server
|
|
68
|
+
if response.headers["mcp-session-id"]
|
|
69
|
+
@session_id = response.headers["mcp-session-id"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
case response.status
|
|
73
|
+
when 200
|
|
74
|
+
content_type = response.headers["content-type"].to_s
|
|
75
|
+
if content_type.include?("text/event-stream")
|
|
76
|
+
write_sse_events(response.body)
|
|
77
|
+
else
|
|
78
|
+
write_line(response.body)
|
|
79
|
+
end
|
|
80
|
+
when 202
|
|
81
|
+
# Notification accepted — no response to write
|
|
82
|
+
nil
|
|
83
|
+
else
|
|
84
|
+
# Return a JSON-RPC error for the request if we can extract the id
|
|
85
|
+
write_jsonrpc_error(json_body, response)
|
|
86
|
+
end
|
|
87
|
+
rescue Faraday::ConnectionFailed => e
|
|
88
|
+
write_jsonrpc_error(json_body, nil, message: "Connection failed: #{e.message}")
|
|
89
|
+
rescue Faraday::TimeoutError => e
|
|
90
|
+
write_jsonrpc_error(json_body, nil, message: "Request timed out: #{e.message}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse SSE stream and write each "message" event as a line to stdout.
|
|
94
|
+
def write_sse_events(body)
|
|
95
|
+
event_type = nil
|
|
96
|
+
body.each_line do |line|
|
|
97
|
+
line = line.rstrip
|
|
98
|
+
if line.start_with?("event:")
|
|
99
|
+
event_type = line.sub("event:", "").strip
|
|
100
|
+
elsif line.start_with?("data:")
|
|
101
|
+
data = line.sub("data:", "").strip
|
|
102
|
+
write_line(data) if event_type == "message" && !data.empty?
|
|
103
|
+
elsif line.empty?
|
|
104
|
+
event_type = nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_line(data)
|
|
110
|
+
$stdout.write(data)
|
|
111
|
+
$stdout.write("\n")
|
|
112
|
+
$stdout.flush
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Best-effort JSON-RPC error response when the hosted server fails.
|
|
116
|
+
# Only writes if the original message was a request (has an "id" field).
|
|
117
|
+
def write_jsonrpc_error(json_body, response, message: nil)
|
|
118
|
+
parsed = begin
|
|
119
|
+
JSON.parse(json_body)
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
return unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
124
|
+
|
|
125
|
+
error_message = message || "Hosted server returned HTTP #{response&.status}"
|
|
126
|
+
error_response = {
|
|
127
|
+
"jsonrpc" => "2.0",
|
|
128
|
+
"id" => parsed["id"],
|
|
129
|
+
"error" => {"code" => -32603, "message" => error_message}
|
|
130
|
+
}
|
|
131
|
+
write_line(JSON.generate(error_response))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_connection
|
|
135
|
+
Faraday.new(url: @url) do |f|
|
|
136
|
+
f.request :authorization, "Bearer", @api_key
|
|
137
|
+
f.headers["X-Superkick-Agent-Id"] = @agent_id
|
|
138
|
+
f.options.timeout = TIMEOUT
|
|
139
|
+
f.options.open_timeout = TIMEOUT
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# InjectHandler — dispatches monitor events to the Injector.
|
|
5
|
+
#
|
|
6
|
+
# Implements the handler contract (handle/flush_pending) used by Monitor.
|
|
7
|
+
# The Injector sends events to the agent's InjectionQueue (fire-and-forget).
|
|
8
|
+
# Pending event management is handled agent-side by the InjectionQueue.
|
|
9
|
+
class InjectHandler
|
|
10
|
+
def initialize(injector:, agent:)
|
|
11
|
+
@injector = injector
|
|
12
|
+
@agent = agent
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Handle a single event. Returns :enqueued, :skipped, or :error.
|
|
16
|
+
def handle(event:)
|
|
17
|
+
@injector.inject(agent_id: @agent.id, event:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# No-op — pending events are managed agent-side by the InjectionQueue.
|
|
21
|
+
def flush_pending
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# PatternGuard matches pty output lines and signals the InputBuffer to
|
|
5
|
+
# suppress injection while the guard is active.
|
|
6
|
+
#
|
|
7
|
+
# name — unique Symbol used as the hash key in InputBuffer#guards
|
|
8
|
+
# pattern — Regexp matched against ANSI-stripped output
|
|
9
|
+
# reason — human-readable explanation shown in superkick status
|
|
10
|
+
# clear_on_submit — if true, the guard is cleared when the user hits Enter/C-c
|
|
11
|
+
PatternGuard = Data.define(:name, :pattern, :reason, :clear_on_submit) do
|
|
12
|
+
def initialize(name:, pattern:, reason:, clear_on_submit: true)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Match against text that has already been stripped of ANSI codes.
|
|
17
|
+
def match?(stripped_text)
|
|
18
|
+
pattern.match?(stripped_text)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Factory helper so callers don't need to remember keyword names.
|
|
23
|
+
def self.pattern_guard(name, pattern, reason, clear_on_submit: true)
|
|
24
|
+
PatternGuard.new(name:, pattern:, reason:, clear_on_submit:)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Agent-side injection queue with TTL, supersede, and priority semantics.
|
|
5
|
+
#
|
|
6
|
+
# The server sends injection requests (rendered prompts) to the agent via
|
|
7
|
+
# `enqueue_injection`. The queue drains locally, checking preconditions
|
|
8
|
+
# (idle state, guards) with zero network latency before writing to the PTY.
|
|
9
|
+
#
|
|
10
|
+
# Queue semantics:
|
|
11
|
+
# - TTL: Stale injections are dropped silently after their TTL expires.
|
|
12
|
+
# - Supersede: A new injection with the same supersede_key replaces any
|
|
13
|
+
# existing queued entry with that key.
|
|
14
|
+
# - Priority: :high (spawn kickoffs, team messages), :normal (monitor
|
|
15
|
+
# events), :low (cost polling). Higher priority drains first.
|
|
16
|
+
# - Max size: Oldest low-priority entries are dropped when the queue is full.
|
|
17
|
+
class InjectionQueue
|
|
18
|
+
Entry = Data.define(:id, :prompt, :monitor_type, :monitor_name,
|
|
19
|
+
:priority, :enqueued_at, :ttl, :supersede_key)
|
|
20
|
+
|
|
21
|
+
PRIORITIES = {high: 0, normal: 1, low: 2}.freeze
|
|
22
|
+
DEFAULT_TTL = 300 # seconds
|
|
23
|
+
DEFAULT_DRAIN_INTERVAL = 0.5 # seconds
|
|
24
|
+
MAX_SIZE = 50
|
|
25
|
+
|
|
26
|
+
attr_reader :entries
|
|
27
|
+
|
|
28
|
+
def initialize(pty_proxy:, idle_threshold:, inject_clear_delay:, control_client: nil,
|
|
29
|
+
drain_interval: DEFAULT_DRAIN_INTERVAL)
|
|
30
|
+
@pty_proxy = pty_proxy
|
|
31
|
+
@idle_threshold = idle_threshold
|
|
32
|
+
@inject_clear_delay = inject_clear_delay
|
|
33
|
+
@control_client = control_client
|
|
34
|
+
@drain_interval = drain_interval
|
|
35
|
+
@entries = []
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@drain_thread = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Enqueue an injection request. Supersedes existing entries with matching key.
|
|
41
|
+
# Returns the Entry or nil if rejected.
|
|
42
|
+
def enqueue(id:, prompt:, monitor_type: nil, monitor_name: nil,
|
|
43
|
+
priority: :normal, ttl: DEFAULT_TTL, supersede_key: nil)
|
|
44
|
+
key = supersede_key || monitor_name.to_s
|
|
45
|
+
pri = PRIORITIES.fetch(priority.to_sym, PRIORITIES[:normal])
|
|
46
|
+
|
|
47
|
+
entry = Entry.new(
|
|
48
|
+
id:, prompt:, monitor_type:, monitor_name:,
|
|
49
|
+
priority: pri,
|
|
50
|
+
enqueued_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
51
|
+
ttl:, supersede_key: key
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
# Supersede: remove existing entries with the same key
|
|
56
|
+
superseded = @entries.select { it.supersede_key == key }
|
|
57
|
+
@entries.reject! { it.supersede_key == key }
|
|
58
|
+
superseded.each { report_result(it.id, :superseded) }
|
|
59
|
+
|
|
60
|
+
@entries << entry
|
|
61
|
+
drop_oldest_if_full
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
entry
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start
|
|
68
|
+
@drain_thread = Thread.new { drain_loop }
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop
|
|
73
|
+
@drain_thread&.kill
|
|
74
|
+
@drain_thread&.join(2)
|
|
75
|
+
@drain_thread = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def size
|
|
79
|
+
@mutex.synchronize { @entries.size }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def drain_loop
|
|
85
|
+
loop do
|
|
86
|
+
entry = next_ready_entry
|
|
87
|
+
if entry
|
|
88
|
+
attempt_injection(entry)
|
|
89
|
+
else
|
|
90
|
+
sleep(@drain_interval)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
rescue => e
|
|
94
|
+
Superkick.logger.error("injection_queue") { "Drain loop error: #{e.message}" }
|
|
95
|
+
retry
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def next_ready_entry
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
+
|
|
102
|
+
# Remove expired entries
|
|
103
|
+
expired, @entries = @entries.partition { (now - it.enqueued_at) > it.ttl }
|
|
104
|
+
expired.each { report_result(it.id, :expired) }
|
|
105
|
+
|
|
106
|
+
# Sort by priority (lower number = higher priority), then by age (oldest first)
|
|
107
|
+
@entries.sort_by! { [it.priority, it.enqueued_at] }
|
|
108
|
+
@entries.first
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def attempt_injection(entry)
|
|
113
|
+
# All checks are LOCAL — no network calls
|
|
114
|
+
idle = @pty_proxy.idle_state
|
|
115
|
+
seconds_idle = idle[:seconds_idle]
|
|
116
|
+
at_prompt = idle[:at_prompt]
|
|
117
|
+
|
|
118
|
+
threshold = @idle_threshold
|
|
119
|
+
|
|
120
|
+
unless seconds_idle && seconds_idle >= threshold
|
|
121
|
+
sleep(@drain_interval)
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Conservative: if not at prompt, require 2× threshold
|
|
126
|
+
unless at_prompt || seconds_idle >= threshold * 2
|
|
127
|
+
sleep(@drain_interval)
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless @pty_proxy.guards_clear?
|
|
132
|
+
sleep(@drain_interval)
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Remove from queue
|
|
137
|
+
@mutex.synchronize { @entries.delete(entry) }
|
|
138
|
+
|
|
139
|
+
# Execute injection sequence (same as the old Injector)
|
|
140
|
+
partial_input = @pty_proxy.partial_input
|
|
141
|
+
delay = @inject_clear_delay
|
|
142
|
+
|
|
143
|
+
@pty_proxy.enqueue_inject("\x03")
|
|
144
|
+
sleep(delay)
|
|
145
|
+
@pty_proxy.enqueue_inject("\r")
|
|
146
|
+
sleep(delay)
|
|
147
|
+
@pty_proxy.enqueue_inject("#{entry.prompt}\r")
|
|
148
|
+
sleep(delay)
|
|
149
|
+
@pty_proxy.enqueue_inject(partial_input) unless partial_input.empty?
|
|
150
|
+
|
|
151
|
+
Superkick.logger.info("injection_queue") { "Injected #{entry.monitor_type}:#{entry.id}" }
|
|
152
|
+
report_result(entry.id, :injected)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def report_result(id, status)
|
|
156
|
+
return unless @control_client
|
|
157
|
+
|
|
158
|
+
Thread.new do
|
|
159
|
+
@control_client.request("injection_result", id:, status: status.to_s, agent_id: @pty_proxy.agent_id)
|
|
160
|
+
rescue => e
|
|
161
|
+
Superkick.logger.debug("injection_queue") { "Result report failed: #{e.message}" }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def drop_oldest_if_full
|
|
166
|
+
return if @entries.size <= MAX_SIZE
|
|
167
|
+
|
|
168
|
+
# Sort so lowest-priority (highest number), oldest entries come first
|
|
169
|
+
@entries.sort_by! { [-it.priority, it.enqueued_at] }
|
|
170
|
+
dropped = @entries.shift(@entries.size - MAX_SIZE)
|
|
171
|
+
dropped.each { report_result(it.id, :dropped) }
|
|
172
|
+
|
|
173
|
+
# Re-sort for drain order
|
|
174
|
+
@entries.sort_by! { [it.priority, it.enqueued_at] }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|