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,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Superkick
|
|
8
|
+
module Buffer
|
|
9
|
+
# Per-agent Unix socket server running inside the `superkick agent` process.
|
|
10
|
+
# The server's Injector and other components send buffer commands here.
|
|
11
|
+
#
|
|
12
|
+
# Supported commands (newline-delimited JSON):
|
|
13
|
+
# get → { ok: true, contents: String | nil }
|
|
14
|
+
# clear → { ok: true }
|
|
15
|
+
# guards_active → { ok: true, active: bool, guards: { name => reason } }
|
|
16
|
+
# idle_state → { ok: true, seconds_idle: Float|nil, at_prompt: bool }
|
|
17
|
+
# inject → enqueues base64-decoded bytes onto PtyProxy's inject queue
|
|
18
|
+
# enqueue_injection → enqueues a rendered prompt onto the InjectionQueue
|
|
19
|
+
# ping → { ok: true }
|
|
20
|
+
class Server
|
|
21
|
+
def initialize(agent_id:, input_buffer:, pty_proxy:, injection_queue: nil, socket_path: nil)
|
|
22
|
+
@agent_id = agent_id
|
|
23
|
+
@input_buffer = input_buffer
|
|
24
|
+
@pty_proxy = pty_proxy
|
|
25
|
+
@injection_queue = injection_queue
|
|
26
|
+
@socket_path = socket_path || Superkick.config.buffer_socket_path(agent_id)
|
|
27
|
+
@server = nil
|
|
28
|
+
@thread = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
33
|
+
FileUtils.rm_f(@socket_path)
|
|
34
|
+
@server = UNIXServer.new(@socket_path)
|
|
35
|
+
@thread = Thread.new { serve }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop
|
|
40
|
+
begin
|
|
41
|
+
@server&.close
|
|
42
|
+
rescue IOError, Errno::EBADF
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
begin
|
|
46
|
+
@thread&.kill
|
|
47
|
+
rescue ThreadError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
FileUtils.rm_f(@socket_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :socket_path
|
|
54
|
+
attr_writer :injection_queue
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def serve
|
|
59
|
+
loop do
|
|
60
|
+
client = @server.accept
|
|
61
|
+
Thread.new(client) { handle(it) }
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
break
|
|
64
|
+
rescue => e
|
|
65
|
+
Superkick.logger.error("buffer:#{@agent_id}") { "Buffer::Server accept error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
66
|
+
break
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle(raw_connection)
|
|
71
|
+
connection = Superkick::Connection.new(raw_connection)
|
|
72
|
+
request = connection.receive_message
|
|
73
|
+
return unless request
|
|
74
|
+
|
|
75
|
+
case request[:command]
|
|
76
|
+
when "get"
|
|
77
|
+
contents = @input_buffer.contents
|
|
78
|
+
connection.reply(contents: contents.empty? ? nil : contents)
|
|
79
|
+
|
|
80
|
+
when "clear"
|
|
81
|
+
@input_buffer.clear
|
|
82
|
+
connection.reply
|
|
83
|
+
|
|
84
|
+
when "guards_active"
|
|
85
|
+
guards = @input_buffer.guards
|
|
86
|
+
connection.reply(active: !guards.empty?, guards: guards)
|
|
87
|
+
|
|
88
|
+
when "idle_state"
|
|
89
|
+
connection.reply(@pty_proxy.idle_state)
|
|
90
|
+
|
|
91
|
+
when "inject"
|
|
92
|
+
bytes = Base64.decode64(request[:data].to_s)
|
|
93
|
+
@pty_proxy.enqueue_inject(bytes)
|
|
94
|
+
connection.reply
|
|
95
|
+
|
|
96
|
+
when "enqueue_injection"
|
|
97
|
+
raise "No injection queue available" unless @injection_queue
|
|
98
|
+
|
|
99
|
+
@injection_queue.enqueue(
|
|
100
|
+
id: request[:id],
|
|
101
|
+
prompt: request[:prompt],
|
|
102
|
+
monitor_type: request[:monitor_type],
|
|
103
|
+
monitor_name: request[:monitor_name],
|
|
104
|
+
priority: request[:priority]&.to_sym || :normal,
|
|
105
|
+
ttl: request[:ttl] || InjectionQueue::DEFAULT_TTL,
|
|
106
|
+
supersede_key: request[:supersede_key]
|
|
107
|
+
)
|
|
108
|
+
connection.reply(status: "queued")
|
|
109
|
+
|
|
110
|
+
when "ping"
|
|
111
|
+
connection.reply
|
|
112
|
+
|
|
113
|
+
else
|
|
114
|
+
raise "unknown command: #{request[:command]}"
|
|
115
|
+
end
|
|
116
|
+
rescue => e
|
|
117
|
+
begin
|
|
118
|
+
connection&.error(e.message)
|
|
119
|
+
rescue IOError, Errno::EPIPE, Errno::ENOTCONN
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
ensure
|
|
123
|
+
connection&.close
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
class Agent < Thor
|
|
6
|
+
package_name "superkick agent"
|
|
7
|
+
|
|
8
|
+
desc "start [-- ARGS]", "Start an AI coding CLI as a Superkick agent"
|
|
9
|
+
long_desc <<~DESC
|
|
10
|
+
Wrap an AI coding CLI (Claude Code, Copilot, Codex, Gemini, Goose) in a
|
|
11
|
+
PTY proxy that enables context injection from monitors. The Superkick
|
|
12
|
+
server must be running for injection to work, but the agent starts fine
|
|
13
|
+
without it.
|
|
14
|
+
|
|
15
|
+
The driver is auto-detected from config.yml or can be set with --driver.
|
|
16
|
+
Any arguments after -- are forwarded to the underlying CLI.
|
|
17
|
+
|
|
18
|
+
Use --team to join an existing agent team as a human member. Team members
|
|
19
|
+
receive digest injections of teammate activity and can communicate via the
|
|
20
|
+
team log. Use --no-log to disable digest injections while remaining on the
|
|
21
|
+
team.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
|
|
25
|
+
superkick agent start
|
|
26
|
+
|
|
27
|
+
superkick agent start -d copilot -- --workspace ~/projects/api
|
|
28
|
+
|
|
29
|
+
superkick agent start --team my-team --role "Code reviewer"
|
|
30
|
+
DESC
|
|
31
|
+
option :driver, type: :string, aliases: "-d",
|
|
32
|
+
desc: "Driver name (claude_code, copilot, codex, gemini)"
|
|
33
|
+
option :driver_config_dir, type: :string, desc: "Override driver config/settings directory"
|
|
34
|
+
option :driver_command, type: :string, desc: "Override driver CLI executable"
|
|
35
|
+
option :team, type: :string, desc: "Join an existing team by team ID"
|
|
36
|
+
option :role, type: :string, desc: "Human-readable role label (e.g. 'Code reviewer')"
|
|
37
|
+
option :no_log, type: :boolean, default: false, desc: "Disable team log digest injections"
|
|
38
|
+
option :agent_id, type: :string, hide: true
|
|
39
|
+
option :headless, type: :boolean, default: false, hide: true
|
|
40
|
+
def start(*args)
|
|
41
|
+
Superkick.load_config!
|
|
42
|
+
|
|
43
|
+
driver_opts = {}
|
|
44
|
+
driver_opts[:cli_command] = options[:driver_command] if options[:driver_command]
|
|
45
|
+
driver_opts[:config_dir] = options[:driver_config_dir] if options[:driver_config_dir]
|
|
46
|
+
|
|
47
|
+
if options[:driver]
|
|
48
|
+
Superkick.use(options[:driver].to_sym, **driver_opts)
|
|
49
|
+
elsif driver_opts.any? && Superkick.driver
|
|
50
|
+
# Re-initialize current driver with overrides
|
|
51
|
+
Superkick.use(Superkick.driver.driver_name, **driver_opts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless Superkick.driver
|
|
55
|
+
warn "No CLI command configured. Use --driver or set `driver:` in config.yml."
|
|
56
|
+
exit(1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
config = Superkick.config
|
|
60
|
+
proxy_opts = {
|
|
61
|
+
command: Superkick.driver.cli_command, args: args,
|
|
62
|
+
idle_threshold: config.idle_threshold,
|
|
63
|
+
inject_clear_delay: config.inject_clear_delay
|
|
64
|
+
}
|
|
65
|
+
proxy_opts[:agent_id] = options[:agent_id] if options[:agent_id]
|
|
66
|
+
proxy_opts[:headless] = true if options[:headless]
|
|
67
|
+
proxy_opts[:team_id] = options[:team] if options[:team]
|
|
68
|
+
proxy_opts[:role] = options[:role] if options[:role]
|
|
69
|
+
proxy_opts[:team_log] = !options[:no_log] if options[:team]
|
|
70
|
+
|
|
71
|
+
pty_proxy = PtyProxy.new(**proxy_opts)
|
|
72
|
+
pty_proxy.run
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc "list", "List all active agents"
|
|
76
|
+
option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
77
|
+
def list
|
|
78
|
+
client = Control.client_from
|
|
79
|
+
|
|
80
|
+
unless client.alive?
|
|
81
|
+
$stdout.puts "Superkick server is not running."
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
result = client.request("list_agents")
|
|
86
|
+
agents = result[:agents] || []
|
|
87
|
+
|
|
88
|
+
if options[:json]
|
|
89
|
+
$stdout.puts JSON.pretty_generate(agents)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if agents.empty?
|
|
94
|
+
$stdout.puts "No active agents."
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
agents.each do |s|
|
|
99
|
+
status_parts = []
|
|
100
|
+
status_parts << "pty-connected" if s[:has_buffer]
|
|
101
|
+
status_parts << "output-log" if s[:has_output_log]
|
|
102
|
+
status = status_parts.any? ? status_parts.join(", ") : "no-pty"
|
|
103
|
+
|
|
104
|
+
$stdout.puts s[:agent_id].to_s
|
|
105
|
+
$stdout.puts " Status: #{status}"
|
|
106
|
+
$stdout.puts " Monitors: #{s[:monitor_count]}"
|
|
107
|
+
$stdout.puts " Registered: #{s[:registered_at]}"
|
|
108
|
+
$stdout.puts " Last notified: #{s[:last_notified] || "never"}"
|
|
109
|
+
|
|
110
|
+
if s[:output_log_path]
|
|
111
|
+
size = File.exist?(s[:output_log_path]) ? File.size(s[:output_log_path]) : 0
|
|
112
|
+
$stdout.puts " Output log: #{s[:output_log_path]} (#{human_size(size)})"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if s[:spawn_info]
|
|
116
|
+
si = s[:spawn_info]
|
|
117
|
+
$stdout.puts " Spawned by: #{si[:spawner_name]} (#{si[:event_type]})"
|
|
118
|
+
$stdout.puts " Spawned at: #{si[:spawned_at]}"
|
|
119
|
+
$stdout.puts " Duration: #{human_duration(si[:spawned_at])}" if si[:spawned_at]
|
|
120
|
+
if si[:parent_agent_id]
|
|
121
|
+
$stdout.puts " Workflow from: #{si[:parent_agent_id]}"
|
|
122
|
+
$stdout.puts " Workflow depth: #{si[:workflow_depth]}" if si[:workflow_depth]
|
|
123
|
+
if si[:workflow_iterations]&.any?
|
|
124
|
+
iterations = si[:workflow_iterations].map { |k, v| "#{k}:#{v}" }.join(", ")
|
|
125
|
+
$stdout.puts " Iterations: #{iterations}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if s[:goal_status]
|
|
131
|
+
$stdout.puts " Goal status: #{s[:goal_status]}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if s[:claimed_at]
|
|
135
|
+
$stdout.puts " Claimed at: #{s[:claimed_at]}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if s[:cost]
|
|
139
|
+
c = s[:cost]
|
|
140
|
+
cost_str = "$#{c[:total_cost_usd]}"
|
|
141
|
+
tokens = "#{c[:total_tokens_in]} in / #{c[:total_tokens_out]} out"
|
|
142
|
+
$stdout.puts " Cost: #{cost_str} (#{tokens})"
|
|
143
|
+
end
|
|
144
|
+
$stdout.puts ""
|
|
145
|
+
end
|
|
146
|
+
rescue Control::Client::ServerUnavailable
|
|
147
|
+
$stdout.puts "Superkick server is not running."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
desc "cost [AGENT_ID]", "Show cost tracking for an agent or all agents"
|
|
151
|
+
option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
152
|
+
def cost(agent_id = nil)
|
|
153
|
+
client = Control.client_from
|
|
154
|
+
|
|
155
|
+
if agent_id
|
|
156
|
+
result = client.request("get_agent_cost", agent_id:)
|
|
157
|
+
if options[:json]
|
|
158
|
+
$stdout.puts JSON.pretty_generate(result)
|
|
159
|
+
else
|
|
160
|
+
$stdout.puts agent_id.to_s
|
|
161
|
+
$stdout.puts " Cost: $#{result[:total_cost_usd]}"
|
|
162
|
+
$stdout.puts " Tokens in: #{result[:total_tokens_in]}"
|
|
163
|
+
$stdout.puts " Tokens out: #{result[:total_tokens_out]}"
|
|
164
|
+
$stdout.puts " Samples: #{result[:sample_count]}"
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
result = client.request("get_cost_summary")
|
|
168
|
+
agents = result[:agents] || []
|
|
169
|
+
|
|
170
|
+
if options[:json]
|
|
171
|
+
$stdout.puts JSON.pretty_generate(agents)
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if agents.empty?
|
|
176
|
+
$stdout.puts "No cost data recorded."
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
total_cost = 0.0
|
|
181
|
+
agents.each do |a|
|
|
182
|
+
c = a[:cost]
|
|
183
|
+
total_cost += c[:total_cost_usd]
|
|
184
|
+
$stdout.puts "#{a[:agent_id]}: $#{c[:total_cost_usd]} " \
|
|
185
|
+
"(#{c[:total_tokens_in]} in / #{c[:total_tokens_out]} out)"
|
|
186
|
+
end
|
|
187
|
+
$stdout.puts "Total: $#{total_cost.round(4)}"
|
|
188
|
+
end
|
|
189
|
+
rescue Control::Client::ServerUnavailable
|
|
190
|
+
$stdout.puts "Superkick server is not running."
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
desc "log AGENT_ID", "Tail an agent's output log"
|
|
194
|
+
option :lines, type: :numeric, aliases: "-n", default: 50,
|
|
195
|
+
desc: "Number of lines to show"
|
|
196
|
+
option :follow, type: :boolean, aliases: "-f", default: true,
|
|
197
|
+
desc: "Follow output in real time"
|
|
198
|
+
def log(agent_id)
|
|
199
|
+
client = Control.client_from
|
|
200
|
+
result = client.request("get_output_log_path", agent_id: agent_id)
|
|
201
|
+
path = result[:path]
|
|
202
|
+
|
|
203
|
+
unless path && File.exist?(path)
|
|
204
|
+
warn "No output log for agent #{agent_id}"
|
|
205
|
+
warn "(Agent may not be running or output capture is not active)"
|
|
206
|
+
exit(1)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
args = ["-n", options[:lines].to_s]
|
|
210
|
+
args << "-f" if options[:follow]
|
|
211
|
+
system("tail", *args, path)
|
|
212
|
+
rescue Control::Client::ServerUnavailable
|
|
213
|
+
# Fall back to checking the file directly on disk
|
|
214
|
+
path = File.join(Superkick.config.output_logs_dir, "#{agent_id}.log")
|
|
215
|
+
unless File.exist?(path)
|
|
216
|
+
warn "No output log for agent #{agent_id}"
|
|
217
|
+
exit(1)
|
|
218
|
+
end
|
|
219
|
+
args = ["-n", options[:lines].to_s]
|
|
220
|
+
args << "-f" if options[:follow]
|
|
221
|
+
system("tail", *args, path)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
desc "stop AGENT_ID", "Stop a spawned agent"
|
|
225
|
+
def stop(agent_id)
|
|
226
|
+
client = Control.client_from
|
|
227
|
+
reply = client.request("terminate_agent", agent_id: agent_id)
|
|
228
|
+
|
|
229
|
+
if reply.success?
|
|
230
|
+
$stdout.puts "Stopping agent #{agent_id}."
|
|
231
|
+
else
|
|
232
|
+
warn "Error: #{reply.error_message}"
|
|
233
|
+
exit(1)
|
|
234
|
+
end
|
|
235
|
+
rescue Control::Client::ServerUnavailable
|
|
236
|
+
warn "Superkick server is not running."
|
|
237
|
+
exit(1)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
desc "attach AGENT_ID", "Attach to a running agent"
|
|
241
|
+
long_desc <<~DESC
|
|
242
|
+
Open a live terminal session to a running agent. By default, attaches in
|
|
243
|
+
read-only mode — you can see the agent's output but cannot type.
|
|
244
|
+
|
|
245
|
+
Use -i for read-write mode, which forwards your keystrokes to the agent.
|
|
246
|
+
Only one client can be read-write at a time. Use -f to force-takeover
|
|
247
|
+
read-write from another client.
|
|
248
|
+
|
|
249
|
+
The escape key (default Ctrl-A) provides session controls:
|
|
250
|
+
|
|
251
|
+
Ctrl-A d Detach from the session
|
|
252
|
+
|
|
253
|
+
Ctrl-A r Request read-write mode (from read-only)
|
|
254
|
+
|
|
255
|
+
Ctrl-A o Demote yourself to read-only
|
|
256
|
+
|
|
257
|
+
Read-write sessions auto-demote to read-only after 5 minutes of
|
|
258
|
+
inactivity (configurable via attach_rw_idle_timeout in config.yml).
|
|
259
|
+
DESC
|
|
260
|
+
option :interactive, type: :boolean, aliases: "-i", default: false,
|
|
261
|
+
desc: "Read-write mode (forward keystrokes to agent)"
|
|
262
|
+
option :force, type: :boolean, aliases: "-f", default: false,
|
|
263
|
+
desc: "Force-takeover read-write mode from another client"
|
|
264
|
+
def attach(agent_id)
|
|
265
|
+
Superkick.load_config!
|
|
266
|
+
config = Superkick.config
|
|
267
|
+
mode = (options[:interactive] || options[:force]) ? :rw : :ro
|
|
268
|
+
force = options[:force]
|
|
269
|
+
escape_key = config.attach_escape_key
|
|
270
|
+
|
|
271
|
+
if config.server_type == :local
|
|
272
|
+
socket_path = File.join(config.run_dir, "attach-#{agent_id}.sock")
|
|
273
|
+
|
|
274
|
+
unless File.exist?(socket_path)
|
|
275
|
+
warn "No attach socket for agent #{agent_id}."
|
|
276
|
+
warn "The agent may not be running, or attach may not be supported."
|
|
277
|
+
exit(1)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
client = Attach.client_from(agent_id:, config:, mode:, escape_key:, force:)
|
|
282
|
+
exit(client.run)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
desc "claim AGENT_ID", "Claim a spawned agent (pause autonomous operation)"
|
|
286
|
+
def claim(agent_id)
|
|
287
|
+
client = Control.client_from
|
|
288
|
+
reply = client.request("claim_agent", agent_id:)
|
|
289
|
+
|
|
290
|
+
if reply.success?
|
|
291
|
+
$stdout.puts "Claimed agent #{agent_id}. Goal checking paused."
|
|
292
|
+
else
|
|
293
|
+
warn "Error: #{reply.error_message}"
|
|
294
|
+
exit(1)
|
|
295
|
+
end
|
|
296
|
+
rescue Control::Client::ServerUnavailable
|
|
297
|
+
warn "Superkick server is not running."
|
|
298
|
+
exit(1)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
desc "unclaim AGENT_ID", "Release a claimed agent back to autonomous operation"
|
|
302
|
+
def unclaim(agent_id)
|
|
303
|
+
client = Control.client_from
|
|
304
|
+
reply = client.request("unclaim_agent", agent_id:)
|
|
305
|
+
|
|
306
|
+
if reply.success?
|
|
307
|
+
$stdout.puts "Released agent #{agent_id}. Goal checking resumed."
|
|
308
|
+
else
|
|
309
|
+
warn "Error: #{reply.error_message}"
|
|
310
|
+
exit(1)
|
|
311
|
+
end
|
|
312
|
+
rescue Control::Client::ServerUnavailable
|
|
313
|
+
warn "Superkick server is not running."
|
|
314
|
+
exit(1)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
desc "report-cost AGENT_ID", "Report cost data for an agent"
|
|
318
|
+
option :tokens_in, type: :numeric, desc: "Total input tokens"
|
|
319
|
+
option :tokens_out, type: :numeric, desc: "Total output tokens"
|
|
320
|
+
option :cost_usd, type: :numeric, desc: "Total cost in USD"
|
|
321
|
+
def report_cost(agent_id)
|
|
322
|
+
unless options[:tokens_in] || options[:tokens_out] || options[:cost_usd]
|
|
323
|
+
warn "At least one of --tokens_in, --tokens_out, or --cost_usd is required."
|
|
324
|
+
exit(1)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
client = Control.client_from
|
|
328
|
+
result = client.request("report_cost",
|
|
329
|
+
agent_id:,
|
|
330
|
+
tokens_in: options[:tokens_in],
|
|
331
|
+
tokens_out: options[:tokens_out],
|
|
332
|
+
cost_usd: options[:cost_usd],
|
|
333
|
+
source: :cli_report)
|
|
334
|
+
|
|
335
|
+
if result.success?
|
|
336
|
+
$stdout.puts "Cost recorded for agent #{agent_id}."
|
|
337
|
+
else
|
|
338
|
+
warn "Error: #{result.error_message}"
|
|
339
|
+
exit(1)
|
|
340
|
+
end
|
|
341
|
+
rescue Control::Client::ServerUnavailable
|
|
342
|
+
warn "Superkick server is not running."
|
|
343
|
+
exit(1)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
desc "add-monitor AGENT_ID MONITOR_NAME", "Add a monitor to a running agent"
|
|
347
|
+
long_desc <<~DESC
|
|
348
|
+
Dynamically add a monitor to a running agent. The monitor type is inferred
|
|
349
|
+
from the name unless --type is given (e.g. two shell monitors can coexist
|
|
350
|
+
under different names like "disk_check" and "mem_check").
|
|
351
|
+
|
|
352
|
+
Config can be passed as inline JSON/YAML or loaded from a file with the
|
|
353
|
+
@ prefix:
|
|
354
|
+
|
|
355
|
+
superkick agent add-monitor agent-1 disk_check -t shell \\
|
|
356
|
+
-c '{"command": "./check.sh", "timeout": 30}'
|
|
357
|
+
|
|
358
|
+
superkick agent add-monitor agent-1 disk_check \\
|
|
359
|
+
-c @monitors/disk_check.yml
|
|
360
|
+
|
|
361
|
+
Privileged monitor types (configured via privileged_types in config.yml)
|
|
362
|
+
cannot be added by the AI agent or via this command.
|
|
363
|
+
DESC
|
|
364
|
+
option :type, type: :string, aliases: "-t",
|
|
365
|
+
desc: "Monitor class type (e.g. 'shell'). Inferred from name if omitted."
|
|
366
|
+
option :config, type: :string, aliases: "-c",
|
|
367
|
+
desc: "JSON/YAML config string or @filepath"
|
|
368
|
+
def add_monitor(agent_id, monitor_name)
|
|
369
|
+
config = parse_config(options[:config])
|
|
370
|
+
config[:type] = options[:type] if options[:type]
|
|
371
|
+
|
|
372
|
+
client = Control.client_from
|
|
373
|
+
result = client.request("add_monitor",
|
|
374
|
+
agent_id:,
|
|
375
|
+
monitor_name:,
|
|
376
|
+
config:)
|
|
377
|
+
|
|
378
|
+
if result.success?
|
|
379
|
+
$stdout.puts "Monitor #{monitor_name} added to agent #{agent_id}."
|
|
380
|
+
else
|
|
381
|
+
warn "Error: #{result.error_message}"
|
|
382
|
+
exit(1)
|
|
383
|
+
end
|
|
384
|
+
rescue Control::Client::ServerUnavailable
|
|
385
|
+
warn "Superkick server is not running."
|
|
386
|
+
exit(1)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
desc "remove-monitor AGENT_ID MONITOR_NAME", "Remove a monitor from a running agent"
|
|
390
|
+
def remove_monitor(agent_id, monitor_name)
|
|
391
|
+
client = Control.client_from
|
|
392
|
+
result = client.request("remove_monitor",
|
|
393
|
+
agent_id:,
|
|
394
|
+
monitor_name:)
|
|
395
|
+
|
|
396
|
+
if result.success?
|
|
397
|
+
$stdout.puts "Monitor #{monitor_name} removed from agent #{agent_id}."
|
|
398
|
+
else
|
|
399
|
+
warn "Error: #{result.error_message}"
|
|
400
|
+
exit(1)
|
|
401
|
+
end
|
|
402
|
+
rescue Control::Client::ServerUnavailable
|
|
403
|
+
warn "Superkick server is not running."
|
|
404
|
+
exit(1)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
desc "add-notifier AGENT_ID NOTIFIER_NAME", "Add a notifier to a running agent"
|
|
408
|
+
long_desc <<~DESC
|
|
409
|
+
Dynamically add a per-agent notifier to a running agent. Per-agent
|
|
410
|
+
notifiers fire alongside global notifiers for that agent's events only.
|
|
411
|
+
|
|
412
|
+
Config can be passed as inline JSON/YAML or loaded from a file with the
|
|
413
|
+
@ prefix:
|
|
414
|
+
|
|
415
|
+
superkick agent add-notifier agent-1 slack_ops -t slack \\
|
|
416
|
+
-c '{"channel": "#ops"}'
|
|
417
|
+
|
|
418
|
+
superkick agent add-notifier agent-1 slack_ops \\
|
|
419
|
+
-c @notifiers/slack_ops.yml
|
|
420
|
+
|
|
421
|
+
Privileged notifier types (configured via notification_privileged_types in
|
|
422
|
+
config.yml) cannot be added by the AI agent or via this command.
|
|
423
|
+
DESC
|
|
424
|
+
option :type, type: :string, aliases: "-t",
|
|
425
|
+
desc: "Notifier class type (e.g. 'slack'). Inferred from name if omitted."
|
|
426
|
+
option :config, type: :string, aliases: "-c",
|
|
427
|
+
desc: "JSON/YAML config string or @filepath"
|
|
428
|
+
def add_notifier(agent_id, notifier_name)
|
|
429
|
+
config = parse_config(options[:config])
|
|
430
|
+
config[:type] = options[:type] if options[:type]
|
|
431
|
+
|
|
432
|
+
client = Control.client_from
|
|
433
|
+
result = client.request("add_notifier",
|
|
434
|
+
agent_id:,
|
|
435
|
+
notifier_name:,
|
|
436
|
+
config:)
|
|
437
|
+
|
|
438
|
+
if result.success?
|
|
439
|
+
$stdout.puts "Notifier #{notifier_name} added to agent #{agent_id}."
|
|
440
|
+
else
|
|
441
|
+
warn "Error: #{result.error_message}"
|
|
442
|
+
exit(1)
|
|
443
|
+
end
|
|
444
|
+
rescue Control::Client::ServerUnavailable
|
|
445
|
+
warn "Superkick server is not running."
|
|
446
|
+
exit(1)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
desc "remove-notifier AGENT_ID NOTIFIER_NAME", "Remove a notifier from a running agent"
|
|
450
|
+
def remove_notifier(agent_id, notifier_name)
|
|
451
|
+
client = Control.client_from
|
|
452
|
+
result = client.request("remove_notifier",
|
|
453
|
+
agent_id:,
|
|
454
|
+
notifier_name:)
|
|
455
|
+
|
|
456
|
+
if result.success?
|
|
457
|
+
$stdout.puts "Notifier #{notifier_name} removed from agent #{agent_id}."
|
|
458
|
+
else
|
|
459
|
+
warn "Error: #{result.error_message}"
|
|
460
|
+
exit(1)
|
|
461
|
+
end
|
|
462
|
+
rescue Control::Client::ServerUnavailable
|
|
463
|
+
warn "Superkick server is not running."
|
|
464
|
+
exit(1)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
default_command :start
|
|
468
|
+
|
|
469
|
+
private
|
|
470
|
+
|
|
471
|
+
def parse_config(value)
|
|
472
|
+
return {} unless value
|
|
473
|
+
|
|
474
|
+
raw = if value.start_with?("@")
|
|
475
|
+
path = File.expand_path(value[1..])
|
|
476
|
+
unless File.exist?(path)
|
|
477
|
+
warn "Config file not found: #{path}"
|
|
478
|
+
exit(1)
|
|
479
|
+
end
|
|
480
|
+
File.read(path)
|
|
481
|
+
else
|
|
482
|
+
value
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
parsed = YAML.safe_load(raw, symbolize_names: true)
|
|
486
|
+
unless parsed.is_a?(Hash)
|
|
487
|
+
warn "Config must be a YAML/JSON object, got: #{parsed.class}"
|
|
488
|
+
exit(1)
|
|
489
|
+
end
|
|
490
|
+
parsed
|
|
491
|
+
rescue Psych::SyntaxError => e
|
|
492
|
+
warn "Invalid YAML/JSON config: #{e.message}"
|
|
493
|
+
exit(1)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def human_size(bytes)
|
|
497
|
+
if bytes < 1024
|
|
498
|
+
"#{bytes} B"
|
|
499
|
+
elsif bytes < 1024 * 1024
|
|
500
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
501
|
+
else
|
|
502
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def human_duration(iso8601_start)
|
|
507
|
+
elapsed = Time.now - Time.iso8601(iso8601_start)
|
|
508
|
+
return "just now" if elapsed < 1
|
|
509
|
+
|
|
510
|
+
parts = []
|
|
511
|
+
hours = (elapsed / 3600).to_i
|
|
512
|
+
minutes = ((elapsed % 3600) / 60).to_i
|
|
513
|
+
seconds = (elapsed % 60).to_i
|
|
514
|
+
|
|
515
|
+
parts << "#{hours}h" if hours > 0
|
|
516
|
+
parts << "#{minutes}m" if minutes > 0
|
|
517
|
+
parts << "#{seconds}s" if parts.empty?
|
|
518
|
+
parts.join(" ")
|
|
519
|
+
rescue
|
|
520
|
+
"unknown"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|