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
@@ -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