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
data/exe/superkick
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
class Agent
|
|
5
|
+
# Runtime — abstract base class for agent process runtimes.
|
|
6
|
+
#
|
|
7
|
+
# A runtime knows how to provision a compute environment for an agent
|
|
8
|
+
# process, terminate it, and check whether it's still alive. The
|
|
9
|
+
# AgentSpawner delegates to a Runtime instead of calling Process.spawn
|
|
10
|
+
# directly.
|
|
11
|
+
#
|
|
12
|
+
# Subclass contract:
|
|
13
|
+
# self.type → unique Symbol (e.g. :local, :docker)
|
|
14
|
+
# provision(agent_id:, config:) → Handle (opaque lifecycle handle)
|
|
15
|
+
# terminate(handle:) → nil
|
|
16
|
+
# alive?(handle:) → Boolean
|
|
17
|
+
# metadata(handle:) → Hash (optional, default {})
|
|
18
|
+
#
|
|
19
|
+
# Subclasses register themselves:
|
|
20
|
+
# Superkick::Agent::Runtime.register(MyRuntime)
|
|
21
|
+
class Runtime
|
|
22
|
+
# ── Registry (stores classes, keyed by type) ─────────────────────────
|
|
23
|
+
@registry = {}
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
include Superkick::Registry
|
|
27
|
+
|
|
28
|
+
def register(runtime_class)
|
|
29
|
+
key = runtime_class.type
|
|
30
|
+
raise ArgumentError, "Agent::Runtime :#{key} already registered" if @registry.key?(key)
|
|
31
|
+
@registry[key] = runtime_class
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lookup(name)
|
|
35
|
+
@registry[name.to_sym] or raise ArgumentError, "Unknown agent runtime: #{name.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def registered
|
|
39
|
+
@registry.dup.freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ── Instance interface ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def self.type
|
|
46
|
+
raise NotImplementedError, "#{self}.type not implemented"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Provision a compute environment and start an agent process in it.
|
|
50
|
+
#
|
|
51
|
+
# @param agent_id [String]
|
|
52
|
+
# @param config [Hash] { env:, command:, working_dir: }
|
|
53
|
+
# @return [Object] opaque handle for lifecycle management
|
|
54
|
+
def provision(agent_id:, config:)
|
|
55
|
+
raise NotImplementedError, "#{self.class}#provision not implemented"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Terminate the agent's compute environment.
|
|
59
|
+
#
|
|
60
|
+
# @param handle [Object] handle returned by #provision
|
|
61
|
+
def terminate(handle:)
|
|
62
|
+
raise NotImplementedError, "#{self.class}#terminate not implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if the agent's compute environment is still running.
|
|
66
|
+
#
|
|
67
|
+
# @param handle [Object] handle returned by #provision
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def alive?(handle:)
|
|
70
|
+
raise NotImplementedError, "#{self.class}#alive? not implemented"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Return runtime-specific metadata for the agent.
|
|
74
|
+
#
|
|
75
|
+
# @param handle [Object] handle returned by #provision
|
|
76
|
+
# @return [Hash]
|
|
77
|
+
def metadata(handle:)
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
class Agent
|
|
5
|
+
class Runtime
|
|
6
|
+
# Local runtime — spawns agent processes on the same machine via
|
|
7
|
+
# Process.spawn. This is the default runtime for local and on-prem
|
|
8
|
+
# deployments.
|
|
9
|
+
class Local < Runtime
|
|
10
|
+
Handle = Data.define(:pid)
|
|
11
|
+
|
|
12
|
+
def self.type = :local
|
|
13
|
+
|
|
14
|
+
def initialize(**) = nil
|
|
15
|
+
|
|
16
|
+
# Spawn a local process.
|
|
17
|
+
#
|
|
18
|
+
# @param agent_id [String]
|
|
19
|
+
# @param config [Hash] { env: Hash, command: Array, working_dir: String }
|
|
20
|
+
# @return [Handle]
|
|
21
|
+
def provision(agent_id:, config:)
|
|
22
|
+
pid = Process.spawn(
|
|
23
|
+
config[:env] || {},
|
|
24
|
+
*config[:command],
|
|
25
|
+
chdir: config[:working_dir],
|
|
26
|
+
in: File::NULL,
|
|
27
|
+
out: File::NULL,
|
|
28
|
+
err: File::NULL
|
|
29
|
+
)
|
|
30
|
+
Process.detach(pid)
|
|
31
|
+
|
|
32
|
+
Superkick.logger.info("runtime:local") { "Started agent PID #{pid} for #{agent_id} in #{config[:working_dir]}" }
|
|
33
|
+
Handle.new(pid:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Send SIGTERM, then SIGKILL after 10 seconds if still alive.
|
|
37
|
+
#
|
|
38
|
+
# @param handle [Handle]
|
|
39
|
+
def terminate(handle:)
|
|
40
|
+
Process.kill("TERM", handle.pid)
|
|
41
|
+
Thread.new do
|
|
42
|
+
sleep 10
|
|
43
|
+
begin
|
|
44
|
+
Process.kill("KILL", handle.pid)
|
|
45
|
+
rescue Errno::ESRCH
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue Errno::ESRCH
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if the process is still running via signal 0.
|
|
54
|
+
#
|
|
55
|
+
# @param handle [Handle]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def alive?(handle:)
|
|
58
|
+
Process.kill(0, handle.pid)
|
|
59
|
+
true
|
|
60
|
+
rescue Errno::ESRCH
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param handle [Handle]
|
|
65
|
+
# @return [Hash]
|
|
66
|
+
def metadata(handle:)
|
|
67
|
+
{pid: handle.pid}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Superkick::Agent::Runtime.register(Superkick::Agent::Runtime::Local)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Canonical, live agent object. Holds its own state and writes it to its
|
|
5
|
+
# own JSON file on every mutation. Monitor thread lifecycle is managed by
|
|
6
|
+
# Supervisor — Agent is a pure state + persistence object.
|
|
7
|
+
class Agent
|
|
8
|
+
TRANSIENT_PATH_KEYS = %i[buffer_socket_path output_log_path attach_socket_path recording_path].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :id, :registered_at, :last_notified_at, :monitors, :notifiers, :spawn_info,
|
|
11
|
+
:goal_status, :goal_summary, :claimed_at, :cost, :team_id, :team_role, :role, :working_dir,
|
|
12
|
+
:environment
|
|
13
|
+
attr_writer :spawn_info
|
|
14
|
+
|
|
15
|
+
def initialize(id:, registered_at:, last_notified_at: nil, monitors: {}, notifiers: {},
|
|
16
|
+
transient_paths: {}, spawn_info: nil, goal_status: nil, goal_summary: nil, claimed_at: nil,
|
|
17
|
+
team_id: nil, team_role: nil, role: nil, working_dir: nil, environment: nil, file_path: nil)
|
|
18
|
+
@id = id
|
|
19
|
+
@registered_at = registered_at
|
|
20
|
+
@last_notified_at = last_notified_at
|
|
21
|
+
@monitors = monitors
|
|
22
|
+
@notifiers = notifiers
|
|
23
|
+
@file_path = file_path
|
|
24
|
+
@persist_mutex = Mutex.new
|
|
25
|
+
@transient_paths = transient_paths
|
|
26
|
+
@spawn_info = spawn_info
|
|
27
|
+
@goal_status = goal_status
|
|
28
|
+
@goal_summary = goal_summary
|
|
29
|
+
@claimed_at = claimed_at
|
|
30
|
+
@team_id = team_id
|
|
31
|
+
@team_role = team_role&.to_sym
|
|
32
|
+
@role = role
|
|
33
|
+
@working_dir = working_dir
|
|
34
|
+
@environment = environment
|
|
35
|
+
@cost = CostAccumulator.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Transient resource paths (buffer socket, output log, attach socket).
|
|
39
|
+
# Attached/detached by the agent process over IPC, persisted to JSON.
|
|
40
|
+
|
|
41
|
+
def attach_path(key, value)
|
|
42
|
+
@transient_paths[key] = value
|
|
43
|
+
persist
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detach_path(key)
|
|
47
|
+
@transient_paths.delete(key)
|
|
48
|
+
persist
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def path(key)
|
|
52
|
+
@transient_paths[key]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Convenience readers — keep call sites concise.
|
|
56
|
+
def buffer_socket_path = path(:buffer_socket_path)
|
|
57
|
+
|
|
58
|
+
def output_log_path = path(:output_log_path)
|
|
59
|
+
|
|
60
|
+
def attach_socket_path = path(:attach_socket_path)
|
|
61
|
+
|
|
62
|
+
def recording_path = path(:recording_path)
|
|
63
|
+
|
|
64
|
+
# Returns the config hash for a single named monitor, or nil.
|
|
65
|
+
def monitor_config(name)
|
|
66
|
+
@monitors[name.to_sym]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the config hash for a single named notifier, or nil.
|
|
70
|
+
def notifier_config(name)
|
|
71
|
+
@notifiers[name.to_sym]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- Mutations -----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def set_monitor_config(name, config)
|
|
77
|
+
@monitors[name.to_sym] = config
|
|
78
|
+
persist
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def set_monitor_field(name, key, value)
|
|
82
|
+
(@monitors[name.to_sym] ||= {})[key.to_sym] = value
|
|
83
|
+
persist
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def remove_monitor(name)
|
|
87
|
+
@monitors.delete(name.to_sym)
|
|
88
|
+
persist
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def set_notifier_config(name, config)
|
|
92
|
+
@notifiers[name.to_sym] = config
|
|
93
|
+
persist
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def remove_notifier(name)
|
|
97
|
+
@notifiers.delete(name.to_sym)
|
|
98
|
+
persist
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def set_last_notified
|
|
102
|
+
@last_notified_at = Time.now.iso8601
|
|
103
|
+
persist
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def set_goal_status(status)
|
|
107
|
+
@goal_status = status
|
|
108
|
+
persist
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def set_goal_summary(summary)
|
|
112
|
+
@goal_summary = summary
|
|
113
|
+
persist
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def claim!
|
|
117
|
+
@claimed_at = Time.now.iso8601
|
|
118
|
+
persist
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def unclaim!
|
|
122
|
+
@claimed_at = nil
|
|
123
|
+
persist
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def claimed?
|
|
127
|
+
!@claimed_at.nil?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def set_team(team_id:, team_role:)
|
|
131
|
+
@team_id = team_id
|
|
132
|
+
@team_role = team_role&.to_sym
|
|
133
|
+
persist
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def set_role(role)
|
|
137
|
+
@role = role
|
|
138
|
+
persist
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def set_working_dir(dir)
|
|
142
|
+
@working_dir = dir
|
|
143
|
+
persist
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def set_environment(env)
|
|
147
|
+
@environment = env
|
|
148
|
+
persist
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Write current state to disk atomically. No-op when file_path is nil.
|
|
152
|
+
def persist
|
|
153
|
+
return unless @file_path
|
|
154
|
+
|
|
155
|
+
@persist_mutex.synchronize do
|
|
156
|
+
tmp = "#{@file_path}.tmp"
|
|
157
|
+
File.write(tmp, JSON.pretty_generate(to_h))
|
|
158
|
+
File.rename(tmp, @file_path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Serialize to the plain-hash form stored in the JSON file.
|
|
163
|
+
def to_h
|
|
164
|
+
h = {
|
|
165
|
+
registered_at: @registered_at,
|
|
166
|
+
last_notified_at: @last_notified_at,
|
|
167
|
+
monitors: @monitors,
|
|
168
|
+
notifiers: @notifiers
|
|
169
|
+
}
|
|
170
|
+
TRANSIENT_PATH_KEYS.each { |k| h[k] = @transient_paths[k] if @transient_paths[k] }
|
|
171
|
+
h[:spawn_info] = @spawn_info if @spawn_info
|
|
172
|
+
h[:goal_status] = @goal_status if @goal_status
|
|
173
|
+
h[:goal_summary] = @goal_summary if @goal_summary
|
|
174
|
+
h[:claimed_at] = @claimed_at if @claimed_at
|
|
175
|
+
h[:team_id] = @team_id if @team_id
|
|
176
|
+
h[:team_role] = @team_role if @team_role
|
|
177
|
+
h[:role] = @role if @role
|
|
178
|
+
h[:working_dir] = @working_dir if @working_dir
|
|
179
|
+
h[:environment] = @environment if @environment
|
|
180
|
+
cost_data = @cost.to_h
|
|
181
|
+
h[:cost] = cost_data if cost_data[:total_tokens_in] > 0 || cost_data[:total_cost_usd] > 0
|
|
182
|
+
h
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.from_h(id, hash, file_path: nil)
|
|
186
|
+
paths = {}
|
|
187
|
+
TRANSIENT_PATH_KEYS.each { |k| paths[k] = hash[k] if hash[k] }
|
|
188
|
+
|
|
189
|
+
new(
|
|
190
|
+
id:,
|
|
191
|
+
registered_at: hash[:registered_at],
|
|
192
|
+
last_notified_at: hash[:last_notified_at],
|
|
193
|
+
monitors: hash[:monitors] || {},
|
|
194
|
+
notifiers: hash[:notifiers] || {},
|
|
195
|
+
transient_paths: paths,
|
|
196
|
+
spawn_info: hash[:spawn_info],
|
|
197
|
+
goal_status: hash[:goal_status],
|
|
198
|
+
goal_summary: hash[:goal_summary],
|
|
199
|
+
claimed_at: hash[:claimed_at],
|
|
200
|
+
team_id: hash[:team_id],
|
|
201
|
+
team_role: hash[:team_role],
|
|
202
|
+
role: hash[:role],
|
|
203
|
+
working_dir: hash[:working_dir],
|
|
204
|
+
environment: hash[:environment],
|
|
205
|
+
file_path:
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Superkick
|
|
8
|
+
# Thread-safe agent registry backed by a per-agent JSON file under
|
|
9
|
+
# agents_dir. Holds live Agent objects; mutations on an Agent
|
|
10
|
+
# write directly to that agent's file with no involvement from the store.
|
|
11
|
+
class AgentStore
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize(dir = Superkick.config.agents_dir)
|
|
15
|
+
@dir = dir
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
FileUtils.mkdir_p(dir)
|
|
18
|
+
@data = load_from_disk
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- Agent lifecycle -------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def add(agent_id, monitors: {})
|
|
24
|
+
id = agent_id.to_s
|
|
25
|
+
raise ArgumentError, "Agent ID '#{id}' already exists" if @mutex.synchronize { @data.key?(id) }
|
|
26
|
+
agent = Agent.new(
|
|
27
|
+
id:,
|
|
28
|
+
registered_at: Time.now.iso8601,
|
|
29
|
+
monitors:,
|
|
30
|
+
file_path: agent_file(id)
|
|
31
|
+
)
|
|
32
|
+
@mutex.synchronize { @data[id] = agent }
|
|
33
|
+
agent.persist
|
|
34
|
+
agent
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def remove(agent_id)
|
|
38
|
+
id = agent_id.to_s
|
|
39
|
+
agent = @mutex.synchronize { @data.delete(id) }
|
|
40
|
+
FileUtils.rm_f(agent_file(id))
|
|
41
|
+
agent
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def has?(agent_id)
|
|
45
|
+
@mutex.synchronize { @data.key?(agent_id.to_s) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the live Agent for the given id, or nil.
|
|
49
|
+
def get(agent_id)
|
|
50
|
+
@mutex.synchronize { @data[agent_id.to_s] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Yields each Agent; Enumerable methods (map, select, …) come free.
|
|
54
|
+
def each
|
|
55
|
+
agents = @mutex.synchronize { @data.values.dup }
|
|
56
|
+
agents.each { yield it }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns all agents belonging to the given team.
|
|
60
|
+
def team(team_id)
|
|
61
|
+
select { it.team_id == team_id }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true if any agent belongs to the given team.
|
|
65
|
+
def team_exists?(team_id)
|
|
66
|
+
any? { it.team_id == team_id }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def agent_file(id)
|
|
72
|
+
File.join(@dir, "#{id}.json")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_from_disk
|
|
76
|
+
Dir.glob(File.join(@dir, "*.json")).each_with_object({}) do |path, h|
|
|
77
|
+
id = File.basename(path, ".json")
|
|
78
|
+
raw = JSON.parse(File.read(path), symbolize_names: true)
|
|
79
|
+
h[id] = Agent.from_h(id, raw, file_path: path)
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
Superkick.logger.warn("agent_store") { "Corrupt agent file — skipping: #{path}" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Attach
|
|
8
|
+
@clients = {}
|
|
9
|
+
extend ClientRegistry
|
|
10
|
+
|
|
11
|
+
# CLI-side attach session. Connects to an agent, enters raw terminal
|
|
12
|
+
# mode, and streams output to the user's terminal.
|
|
13
|
+
# In read-write mode, keystrokes are forwarded to the PTY.
|
|
14
|
+
#
|
|
15
|
+
# The default implementation uses a Unix socket. In hosted mode, a
|
|
16
|
+
# subclass (Hosted::Attach::Client) uses a WebSocket.
|
|
17
|
+
#
|
|
18
|
+
# Use Attach.client_from to construct the right implementation for the
|
|
19
|
+
# configured server type.
|
|
20
|
+
#
|
|
21
|
+
# Escape sequences (default prefix: Ctrl-A):
|
|
22
|
+
# d — detach
|
|
23
|
+
# c — claim agent (pause autonomous operation)
|
|
24
|
+
# u — unclaim agent (resume autonomous operation)
|
|
25
|
+
# w — request RW promotion (only if slot is vacant)
|
|
26
|
+
# W — forced RW promotion (demotes existing RW holder)
|
|
27
|
+
# r — voluntary demotion to read-only
|
|
28
|
+
class Client
|
|
29
|
+
CTRL_KEY_NAMES = Hash.new { |_, byte| "0x#{byte.to_s(16).rjust(2, "0")}" }.tap do |h|
|
|
30
|
+
(1..26).each { |i| h[i] = "Ctrl-#{(i + 64).chr}" }
|
|
31
|
+
end.freeze
|
|
32
|
+
|
|
33
|
+
def self.from(agent_id:, config: Superkick.config, mode: :ro, escape_key: "\x01", force: false)
|
|
34
|
+
socket_path = File.join(config.run_dir, "attach-#{agent_id}.sock")
|
|
35
|
+
new(socket_path:, mode:, escape_key:, force:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(socket_path: nil, mode: :ro, escape_key: "\x01", force: false)
|
|
39
|
+
@mode = mode
|
|
40
|
+
@escape_key = escape_key.ord
|
|
41
|
+
@force = force
|
|
42
|
+
@prev_escape_byte = nil
|
|
43
|
+
|
|
44
|
+
if socket_path
|
|
45
|
+
@socket = UNIXSocket.new(socket_path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Blocking — returns exit code (0 on clean detach, 1 on error).
|
|
50
|
+
def run
|
|
51
|
+
# Send hello
|
|
52
|
+
hello = {mode: @mode.to_s}
|
|
53
|
+
hello[:force] = true if @force && @mode == :rw
|
|
54
|
+
write_json_frame(Protocol::HELLO, hello)
|
|
55
|
+
|
|
56
|
+
# Read meta (or error)
|
|
57
|
+
frame = read_frame
|
|
58
|
+
if frame && frame[0] == Protocol::ERROR
|
|
59
|
+
err = Protocol.decode_json(frame[1])
|
|
60
|
+
warn "attach: #{err[:message]}"
|
|
61
|
+
return 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if frame && frame[0] == Protocol::META
|
|
65
|
+
meta = Protocol.decode_json(frame[1])
|
|
66
|
+
print_banner(meta)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Read history
|
|
70
|
+
frame = read_frame
|
|
71
|
+
if frame && frame[0] == Protocol::HISTORY
|
|
72
|
+
$stdout.write(frame[1])
|
|
73
|
+
$stdout.flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Start output reader thread
|
|
77
|
+
output_thread = Thread.new { read_output }
|
|
78
|
+
|
|
79
|
+
# All clients use the same input forwarding loop.
|
|
80
|
+
# RO clients' keystrokes are discarded server-side (except COMMAND frames).
|
|
81
|
+
forward_stdin
|
|
82
|
+
|
|
83
|
+
output_thread.kill
|
|
84
|
+
0
|
|
85
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
86
|
+
warn "attach: Cannot connect to agent (is it running?)"
|
|
87
|
+
1
|
|
88
|
+
ensure
|
|
89
|
+
close
|
|
90
|
+
restore_terminal
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Transport methods — default implementation uses Unix socket with Protocol framing.
|
|
94
|
+
|
|
95
|
+
def read_frame
|
|
96
|
+
Protocol.read_frame(@socket)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_frame(type, data)
|
|
100
|
+
Protocol.write_frame(@socket, type, data)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_json_frame(type, hash)
|
|
104
|
+
Protocol.write_json_frame(@socket, type, hash)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
@socket&.close
|
|
109
|
+
rescue IOError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def print_banner(meta)
|
|
116
|
+
prefix = CTRL_KEY_NAMES[@escape_key]
|
|
117
|
+
warn "Attached to agent #{meta[:agent_id]} " \
|
|
118
|
+
"[#{(@mode == :rw) ? "read-write" : "read-only"}]"
|
|
119
|
+
warn "Detach: #{prefix}, d | RW: #{prefix}, w | " \
|
|
120
|
+
"Force RW: #{prefix}, W | RO: #{prefix}, r | " \
|
|
121
|
+
"Claim: #{prefix}, c | Unclaim: #{prefix}, u"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read_output
|
|
125
|
+
loop do
|
|
126
|
+
frame = read_frame
|
|
127
|
+
break unless frame
|
|
128
|
+
|
|
129
|
+
type, payload = frame
|
|
130
|
+
case type
|
|
131
|
+
when Protocol::OUTPUT
|
|
132
|
+
$stdout.write(payload)
|
|
133
|
+
$stdout.flush
|
|
134
|
+
when Protocol::META
|
|
135
|
+
# Updated metadata (future use)
|
|
136
|
+
when Protocol::NOTIFY
|
|
137
|
+
msg = Protocol.decode_json(payload)
|
|
138
|
+
warn "\nattach: #{msg[:message]}"
|
|
139
|
+
when Protocol::ERROR
|
|
140
|
+
err = Protocol.decode_json(payload)
|
|
141
|
+
warn "\nattach: #{err[:message]}"
|
|
142
|
+
break
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
146
|
+
warn "\nSession disconnected."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def forward_stdin
|
|
150
|
+
@prev_escape_byte = nil
|
|
151
|
+
|
|
152
|
+
setup_winch_handler
|
|
153
|
+
|
|
154
|
+
$stdin.raw do
|
|
155
|
+
loop do
|
|
156
|
+
chunk = $stdin.readpartial(4096)
|
|
157
|
+
|
|
158
|
+
action = detect_escape(chunk)
|
|
159
|
+
case action
|
|
160
|
+
when :detach
|
|
161
|
+
warn "\nDetached."
|
|
162
|
+
break
|
|
163
|
+
when :claim
|
|
164
|
+
send_command("claim")
|
|
165
|
+
warn "\nClaim requested."
|
|
166
|
+
next
|
|
167
|
+
when :unclaim
|
|
168
|
+
send_command("unclaim")
|
|
169
|
+
warn "\nUnclaim requested."
|
|
170
|
+
next
|
|
171
|
+
when :promote_rw
|
|
172
|
+
send_command("promote_rw")
|
|
173
|
+
next
|
|
174
|
+
when :force_promote_rw
|
|
175
|
+
send_command("force_promote_rw")
|
|
176
|
+
next
|
|
177
|
+
when :demote_ro
|
|
178
|
+
send_command("demote_ro")
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
write_frame(Protocol::INPUT, chunk)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
rescue IOError, Errno::EIO
|
|
186
|
+
# stdin closed or agent ended
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Detect escape sequences: Ctrl-A prefix followed by a command byte.
|
|
190
|
+
def detect_escape(chunk)
|
|
191
|
+
chunk.each_byte do |b|
|
|
192
|
+
if @prev_escape_byte == @escape_key
|
|
193
|
+
@prev_escape_byte = nil
|
|
194
|
+
case b
|
|
195
|
+
when 0x64 then return :detach # 'd'
|
|
196
|
+
when 0x63 then return :claim # 'c'
|
|
197
|
+
when 0x75 then return :unclaim # 'u'
|
|
198
|
+
when 0x77 then return :promote_rw # 'w'
|
|
199
|
+
when 0x57 then return :force_promote_rw # 'W'
|
|
200
|
+
when 0x72 then return :demote_ro # 'r'
|
|
201
|
+
# else: not a recognized escape — fall through
|
|
202
|
+
end
|
|
203
|
+
else
|
|
204
|
+
@prev_escape_byte = (b == @escape_key) ? b : nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# For backwards compatibility — tests may call this directly
|
|
211
|
+
def detect_detach(chunk)
|
|
212
|
+
detect_escape(chunk) == :detach
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def send_command(action)
|
|
216
|
+
write_json_frame(Protocol::COMMAND, {action:})
|
|
217
|
+
rescue IOError, Errno::EPIPE
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def setup_winch_handler
|
|
222
|
+
Signal.trap("WINCH") do
|
|
223
|
+
rows, cols = begin
|
|
224
|
+
$stdout.winsize
|
|
225
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
226
|
+
[24, 80]
|
|
227
|
+
end
|
|
228
|
+
begin
|
|
229
|
+
write_json_frame(Protocol::RESIZE, {rows:, cols:})
|
|
230
|
+
rescue IOError, Errno::EPIPE
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def restore_terminal
|
|
237
|
+
return unless $stdin.tty?
|
|
238
|
+
|
|
239
|
+
$stdin.cooked!
|
|
240
|
+
rescue Errno::ENOTTY, Errno::EIO, IOError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|