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.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. metadata +404 -0
data/exe/superkick ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/superkick"
5
+
6
+ Superkick::CLI.start(ARGV)
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime"
4
+ require_relative "runtimes/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