superkick 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "reply"
|
|
8
|
+
|
|
9
|
+
module Superkick
|
|
10
|
+
module Control
|
|
11
|
+
# Unix socket server — the control plane's main endpoint.
|
|
12
|
+
# One thread per connection (connections are short-lived request/response).
|
|
13
|
+
#
|
|
14
|
+
# Commands are dispatched dynamically to cmd_<command> private methods.
|
|
15
|
+
class Server
|
|
16
|
+
def initialize(store:, injector:, supervisor:, notification_dispatcher:, buffer_client: nil, config: Superkick.config,
|
|
17
|
+
agent_spawner: nil, approval_store: nil, budget_checker: nil, team_log_store: nil,
|
|
18
|
+
team_artifact_store: nil, server: nil, attach_relay_store: nil)
|
|
19
|
+
@store = store
|
|
20
|
+
@injector = injector
|
|
21
|
+
@supervisor = supervisor
|
|
22
|
+
@config = config
|
|
23
|
+
@buffer_client = buffer_client || Buffer.client_from(store:, config:)
|
|
24
|
+
@agent_spawner = agent_spawner
|
|
25
|
+
@approval_store = approval_store
|
|
26
|
+
@budget_checker = budget_checker
|
|
27
|
+
@team_log_store = team_log_store
|
|
28
|
+
@team_artifact_store = team_artifact_store
|
|
29
|
+
@server = server
|
|
30
|
+
@notification_dispatcher = notification_dispatcher
|
|
31
|
+
@attach_relay_store = attach_relay_store
|
|
32
|
+
@accept_thread = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start
|
|
36
|
+
unless @server
|
|
37
|
+
FileUtils.rm_f(@config.socket_path)
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(@config.socket_path))
|
|
39
|
+
@server = UNIXServer.new(@config.socket_path)
|
|
40
|
+
end
|
|
41
|
+
@accept_thread = Thread.new { accept_loop }
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop
|
|
46
|
+
begin
|
|
47
|
+
@server&.close
|
|
48
|
+
rescue IOError, Errno::EBADF
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
begin
|
|
52
|
+
@accept_thread&.kill
|
|
53
|
+
rescue ThreadError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
FileUtils.rm_f(@config.socket_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def accept_loop
|
|
62
|
+
loop do
|
|
63
|
+
connection = @server.accept
|
|
64
|
+
Thread.new(connection) { handle(it) }
|
|
65
|
+
rescue IOError, Errno::EBADF
|
|
66
|
+
break
|
|
67
|
+
rescue => e
|
|
68
|
+
Superkick.logger.error("control_server") { "Control::Server accept error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle(raw_connection)
|
|
73
|
+
connection = Superkick::Connection.new(raw_connection)
|
|
74
|
+
request = connection.receive_message
|
|
75
|
+
unless request
|
|
76
|
+
connection.close
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
connection.reply(dispatch(request) || {})
|
|
81
|
+
rescue => e
|
|
82
|
+
Superkick.logger.error("control_server") { "Control::Server dispatch error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
83
|
+
begin
|
|
84
|
+
connection&.error(e.message)
|
|
85
|
+
rescue IOError, Errno::EPIPE, Errno::ENOTCONN
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
ensure
|
|
89
|
+
connection&.close
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def dispatch(request)
|
|
93
|
+
method_name = "cmd_#{request[:command].tr("-", "_")}"
|
|
94
|
+
unless respond_to?(method_name, true)
|
|
95
|
+
raise "Unknown command: #{request[:command]}"
|
|
96
|
+
end
|
|
97
|
+
send(method_name, request)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def cmd_ping(_req)
|
|
101
|
+
{version: Superkick::VERSION}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cmd_register(req)
|
|
105
|
+
agent_id = req[:agent_id]
|
|
106
|
+
yaml_monitors = @config.monitors || {}
|
|
107
|
+
@store.add(agent_id, monitors: yaml_monitors)
|
|
108
|
+
|
|
109
|
+
# Store the agent's working directory for monitor config resolution.
|
|
110
|
+
if req[:working_dir]
|
|
111
|
+
agent = @store.get(agent_id)
|
|
112
|
+
agent&.set_working_dir(req[:working_dir])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Human team member joining via --team flag
|
|
116
|
+
if req[:team_id]
|
|
117
|
+
agent = @store.get(agent_id)
|
|
118
|
+
if agent
|
|
119
|
+
agent.set_team(team_id: req[:team_id], team_role: :member)
|
|
120
|
+
agent.set_role(req[:role]) if req[:role]
|
|
121
|
+
|
|
122
|
+
if req[:team_log] != false
|
|
123
|
+
agent.set_monitor_config(:team_log, {type: :team_log, team_id: req[:team_id]})
|
|
124
|
+
@supervisor.enqueue(:add_monitor, agent_id:, monitor_name: :team_log)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
dispatch_notification(
|
|
128
|
+
event: {event_type: :agent_spawned, monitor_type: :system, monitor_name: :system,
|
|
129
|
+
team_id: req[:team_id]},
|
|
130
|
+
agent_id:,
|
|
131
|
+
message: "#{agent_id} joined the team"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Build environment request from all registered probes.
|
|
137
|
+
environment_request = collect_environment_actions
|
|
138
|
+
{agent_id:, environment_request:}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cmd_register_environment(req)
|
|
142
|
+
agent_id = req[:agent_id]
|
|
143
|
+
environment = req[:environment] || {}
|
|
144
|
+
|
|
145
|
+
agent = @store.get(agent_id)
|
|
146
|
+
return {agent_id:} unless agent
|
|
147
|
+
|
|
148
|
+
agent.set_environment(environment)
|
|
149
|
+
|
|
150
|
+
# Run server-side probes with the environment snapshot.
|
|
151
|
+
probe_monitors = Monitor.detect_all(environment:)
|
|
152
|
+
yaml_monitors = agent.monitors || {}
|
|
153
|
+
|
|
154
|
+
# YAML is the base; probe-detected configs overlay per-name.
|
|
155
|
+
merged = yaml_monitors.merge(probe_monitors) { |_name, yaml_config, probe_config|
|
|
156
|
+
yaml_config.merge(probe_config)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Replace monitors with the merged set.
|
|
160
|
+
merged.each { |name, config| agent.set_monitor_config(name, config) }
|
|
161
|
+
|
|
162
|
+
@supervisor.enqueue(:register, agent_id:)
|
|
163
|
+
{agent_id:}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cmd_unregister(req)
|
|
167
|
+
agent_id = req[:agent_id]
|
|
168
|
+
|
|
169
|
+
# Dispatch lifecycle notification when a team member leaves
|
|
170
|
+
agent = @store.get(agent_id)
|
|
171
|
+
if agent&.team_id && agent&.team_role == :member
|
|
172
|
+
dispatch_notification(
|
|
173
|
+
event: {event_type: :agent_terminated, monitor_type: :system, monitor_name: :system,
|
|
174
|
+
team_id: agent.team_id},
|
|
175
|
+
agent_id:,
|
|
176
|
+
message: "#{agent_id} left the team"
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@supervisor.enqueue(:unregister, agent_id:)
|
|
181
|
+
@store.remove(agent_id)
|
|
182
|
+
@agent_spawner&.agent_ended(agent_id)
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def cmd_list_agents(req)
|
|
187
|
+
source = if req[:team_id]
|
|
188
|
+
@store.team(req[:team_id])
|
|
189
|
+
else
|
|
190
|
+
@store.to_a
|
|
191
|
+
end
|
|
192
|
+
source = source.select { it.spawn_info } if req[:spawned_only]
|
|
193
|
+
|
|
194
|
+
agents = source.map { serialize_agent(it) }
|
|
195
|
+
{agents:}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def cmd_list_teams(_req)
|
|
199
|
+
teams = {}
|
|
200
|
+
@store.each do |agent|
|
|
201
|
+
next unless agent.team_id
|
|
202
|
+
entry = teams[agent.team_id] ||= {team_id: agent.team_id, agent_count: 0}
|
|
203
|
+
entry[:agent_count] += 1
|
|
204
|
+
if agent.team_role == :lead
|
|
205
|
+
entry[:lead_agent_id] = agent.id
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
{teams: teams.values}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cmd_register_buffer(req)
|
|
212
|
+
agent_id = req[:agent_id]
|
|
213
|
+
agent = @store.get(agent_id)
|
|
214
|
+
agent&.attach_path(:buffer_socket_path, @config.buffer_socket_path(agent_id))
|
|
215
|
+
agent&.attach_path(:output_log_path, req[:output_log_path]) if req[:output_log_path]
|
|
216
|
+
agent&.attach_path(:recording_path, req[:recording_path]) if req[:recording_path]
|
|
217
|
+
Superkick.logger.info("control_server") { "Buffer socket registered for #{agent_id}" }
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def cmd_unregister_buffer(req)
|
|
222
|
+
@store.get(req[:agent_id])&.detach_path(:buffer_socket_path)
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def cmd_register_attach(req)
|
|
227
|
+
agent_id = req[:agent_id]
|
|
228
|
+
@store.get(agent_id)&.attach_path(:attach_socket_path, @config.attach_socket_path(agent_id))
|
|
229
|
+
Superkick.logger.info("control_server") { "Attach socket registered for #{agent_id}" }
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def cmd_unregister_attach(req)
|
|
234
|
+
@store.get(req[:agent_id])&.detach_path(:attach_socket_path)
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def cmd_has_buffer(req)
|
|
239
|
+
{has_buffer: !@store.get(req[:agent_id])&.buffer_socket_path.nil?}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def cmd_get_output_log_path(req)
|
|
243
|
+
agent_id = req[:agent_id]
|
|
244
|
+
agent = @store.get(agent_id)
|
|
245
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
246
|
+
{path: agent.output_log_path}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def cmd_get_input_buffer(req)
|
|
250
|
+
proxy_buffer(req[:agent_id], "get")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def cmd_clear_input_buffer(req)
|
|
254
|
+
proxy_buffer(req[:agent_id], "clear")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def cmd_guards_active(req)
|
|
258
|
+
proxy_buffer(req[:agent_id], "guards_active")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def cmd_inject_input(req)
|
|
262
|
+
proxy_buffer(req[:agent_id], "inject", data: req[:data])
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def cmd_injection_result(req)
|
|
266
|
+
agent_id = req[:agent_id]
|
|
267
|
+
id = req[:id]
|
|
268
|
+
status = req[:status]&.to_sym
|
|
269
|
+
|
|
270
|
+
case status
|
|
271
|
+
when :injected
|
|
272
|
+
agent = @store.get(agent_id)
|
|
273
|
+
agent&.set_last_notified
|
|
274
|
+
|
|
275
|
+
dispatch_notification(
|
|
276
|
+
event: {event_type: :injection_completed, monitor_type: :system, monitor_name: :injection_queue},
|
|
277
|
+
agent_id:,
|
|
278
|
+
message: "Injection #{id} delivered to #{agent_id}"
|
|
279
|
+
)
|
|
280
|
+
when :expired, :superseded, :dropped
|
|
281
|
+
Superkick.logger.debug("injection") { "Injection #{id} #{status} for #{agent_id}" }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def cmd_attach_event(req)
|
|
288
|
+
agent_id = req[:agent_id]
|
|
289
|
+
event_type = req[:event_type]&.to_sym
|
|
290
|
+
reason = req[:reason]
|
|
291
|
+
|
|
292
|
+
valid_events = %i[attach_promoted attach_demoted attach_idle_timeout attach_force_takeover]
|
|
293
|
+
return unless valid_events.include?(event_type)
|
|
294
|
+
|
|
295
|
+
dispatch_notification(
|
|
296
|
+
event: {event_type:, monitor_type: :attach, monitor_name: :attach},
|
|
297
|
+
agent_id:,
|
|
298
|
+
message: reason || "Attach event #{event_type} for #{agent_id}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def cmd_add_monitor(req)
|
|
305
|
+
agent_id = req[:agent_id]
|
|
306
|
+
name = req[:monitor_name]
|
|
307
|
+
config = req[:config] || {}
|
|
308
|
+
monitor_type = config[:type] || name
|
|
309
|
+
raise_if_privileged!(name, monitor_type, action: "add")
|
|
310
|
+
@store.get(agent_id)&.set_monitor_config(name, config)
|
|
311
|
+
@supervisor.enqueue(:add_monitor, agent_id:, monitor_name: name)
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def cmd_remove_monitor(req)
|
|
316
|
+
agent_id = req[:agent_id]
|
|
317
|
+
name = req[:monitor_name]
|
|
318
|
+
agent = @store.get(agent_id)
|
|
319
|
+
existing_config = agent&.monitor_config(name) || {}
|
|
320
|
+
monitor_type = existing_config[:type] || name
|
|
321
|
+
raise_if_privileged!(name, monitor_type, action: "remove")
|
|
322
|
+
@supervisor.enqueue(:remove_monitor, agent_id:, monitor_name: name)
|
|
323
|
+
agent&.remove_monitor(name)
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def cmd_list_monitors(req)
|
|
328
|
+
agent_id = req[:agent_id]
|
|
329
|
+
agent = @store.get(agent_id)
|
|
330
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
331
|
+
|
|
332
|
+
monitors = agent.monitors.reject { |name, config| monitor_hidden?(name, config) }
|
|
333
|
+
.map { |name, config| {name:, type: config[:type] || name} }
|
|
334
|
+
|
|
335
|
+
{monitors:}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def cmd_restart_monitors(req)
|
|
339
|
+
@supervisor.enqueue(:restart_monitors, agent_id: req[:agent_id])
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# -- Notifier management --------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def cmd_add_notifier(req)
|
|
346
|
+
agent_id = req[:agent_id]
|
|
347
|
+
name = req[:notifier_name]&.to_sym
|
|
348
|
+
config = req[:config] || {}
|
|
349
|
+
notifier_type = config[:type] || name
|
|
350
|
+
raise_if_notifier_privileged!(name, notifier_type, action: "add")
|
|
351
|
+
agent = @store.get(agent_id)
|
|
352
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
353
|
+
agent.set_notifier_config(name, config)
|
|
354
|
+
@notification_dispatcher.add_agent_notifier(agent_id, name)
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def cmd_remove_notifier(req)
|
|
359
|
+
agent_id = req[:agent_id]
|
|
360
|
+
name = req[:notifier_name]&.to_sym
|
|
361
|
+
agent = @store.get(agent_id)
|
|
362
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
363
|
+
existing_config = agent.notifier_config(name) || {}
|
|
364
|
+
notifier_type = existing_config[:type] || name
|
|
365
|
+
raise_if_notifier_privileged!(name, notifier_type, action: "remove")
|
|
366
|
+
@notification_dispatcher.remove_agent_notifier(agent_id, name)
|
|
367
|
+
agent.remove_notifier(name)
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def cmd_list_notifiers(req)
|
|
372
|
+
agent_id = req[:agent_id]
|
|
373
|
+
agent = @store.get(agent_id)
|
|
374
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
375
|
+
|
|
376
|
+
notifiers = agent.notifiers.reject { |name, config| notifier_hidden?(name, config) }
|
|
377
|
+
.map { |name, config| {name:, type: config[:type] || name} }
|
|
378
|
+
|
|
379
|
+
{notifiers:}
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def cmd_get_privileged_notifiers(_req)
|
|
383
|
+
{
|
|
384
|
+
names: @config.notifications.select { |n| n.is_a?(Hash) && n[:privileged] == true && n[:name] }.map { it[:name].to_sym },
|
|
385
|
+
types: @config.notification_privileged_types
|
|
386
|
+
}
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# -- Discovery -----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def cmd_discover_monitors(req)
|
|
392
|
+
monitors = Monitor.registered.each_with_object([]) do |(type, klass), list|
|
|
393
|
+
next if monitor_type_hidden?(type)
|
|
394
|
+
|
|
395
|
+
probe_klass = klass.probe_class
|
|
396
|
+
entry = {
|
|
397
|
+
type: type.to_s,
|
|
398
|
+
description: klass.description,
|
|
399
|
+
required_config: klass.required_config,
|
|
400
|
+
event_types: klass.event_types
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if probe_klass
|
|
404
|
+
entry[:probe] = {
|
|
405
|
+
description: probe_klass.respond_to?(:description) ? probe_klass.description : nil
|
|
406
|
+
}
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
list << entry
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
result = {available_monitors: monitors}
|
|
413
|
+
|
|
414
|
+
# Include probe-detected configs from the agent's stored environment
|
|
415
|
+
agent_id = req[:agent_id]
|
|
416
|
+
if agent_id
|
|
417
|
+
agent = @store.get(agent_id)
|
|
418
|
+
if agent&.environment
|
|
419
|
+
detected = Monitor.detect_all(environment: agent.environment)
|
|
420
|
+
detected = filter_privileged_detected(detected)
|
|
421
|
+
result[:detected] = detected unless detected.empty?
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
result
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def cmd_discover_goals(_req)
|
|
429
|
+
goals = Goal.registered.each_with_object([]) do |(type, klass), list|
|
|
430
|
+
entry = {type: type.to_s}
|
|
431
|
+
entry[:description] = klass.description if klass.description
|
|
432
|
+
entry[:required_config] = klass.required_config unless klass.required_config.empty?
|
|
433
|
+
list << entry
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
{available_goals: goals}
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def cmd_discover_notifiers(_req)
|
|
440
|
+
privileged_types = @config.notification_privileged_types || []
|
|
441
|
+
|
|
442
|
+
notifiers = Notifier.registered.each_with_object([]) do |(type, klass), list|
|
|
443
|
+
next if privileged_types.include?(type.to_sym)
|
|
444
|
+
|
|
445
|
+
entry = {type: type.to_s}
|
|
446
|
+
entry[:description] = klass.description if klass.respond_to?(:description) && klass.description
|
|
447
|
+
entry[:stateful] = true if klass.method_defined?(:stateful?) && klass.new(state_store: nil).stateful?
|
|
448
|
+
list << entry
|
|
449
|
+
rescue => e
|
|
450
|
+
Superkick.logger.debug("control") { "Error probing notifier #{type}: #{e.message}" }
|
|
451
|
+
list << {type: type.to_s}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
{available_notifiers: notifiers}
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# -- Agent claim/unclaim -------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def cmd_claim_agent(req)
|
|
460
|
+
agent_id = req[:agent_id]
|
|
461
|
+
agent = @store.get(agent_id)
|
|
462
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
463
|
+
raise "Not a spawned agent" unless agent.spawn_info
|
|
464
|
+
raise "Agent already claimed" if agent.claimed?
|
|
465
|
+
|
|
466
|
+
agent.claim!
|
|
467
|
+
@supervisor.pause_goal_checker(agent_id)
|
|
468
|
+
|
|
469
|
+
dispatch_notification(
|
|
470
|
+
event: {event_type: :agent_claimed, monitor_type: :spawner,
|
|
471
|
+
monitor_name: agent.spawn_info[:spawner_name] || :system},
|
|
472
|
+
agent_id:,
|
|
473
|
+
message: "Agent #{agent_id} claimed by user"
|
|
474
|
+
)
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def cmd_unclaim_agent(req)
|
|
479
|
+
agent_id = req[:agent_id]
|
|
480
|
+
agent = @store.get(agent_id)
|
|
481
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
482
|
+
raise "Agent is not claimed" unless agent.claimed?
|
|
483
|
+
|
|
484
|
+
agent.unclaim!
|
|
485
|
+
@supervisor.resume_goal_checker(agent_id)
|
|
486
|
+
|
|
487
|
+
dispatch_notification(
|
|
488
|
+
event: {event_type: :agent_unclaimed, monitor_type: :spawner,
|
|
489
|
+
monitor_name: agent.spawn_info&.dig(:spawner_name) || :system},
|
|
490
|
+
agent_id:,
|
|
491
|
+
message: "Agent #{agent_id} released to autonomous operation"
|
|
492
|
+
)
|
|
493
|
+
nil
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# -- Spawner management --------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def cmd_list_spawners(_req)
|
|
499
|
+
{spawners: @supervisor.spawner_status}
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def cmd_stop_spawner(req)
|
|
503
|
+
name = req[:spawner_name]&.to_sym
|
|
504
|
+
raise "Missing spawner_name" unless name
|
|
505
|
+
@supervisor.stop_spawner(name)
|
|
506
|
+
nil
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def cmd_start_spawner(req)
|
|
510
|
+
name = req[:spawner_name]&.to_sym
|
|
511
|
+
raise "Missing spawner_name" unless name
|
|
512
|
+
config = @config.spawners[name]
|
|
513
|
+
raise "Unknown spawner: #{name}" unless config
|
|
514
|
+
@supervisor.enqueue(:start_spawner, monitor_name: name, config:)
|
|
515
|
+
nil
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def cmd_terminate_agent(req)
|
|
519
|
+
agent_id = req[:agent_id]
|
|
520
|
+
agent = @store.get(agent_id)
|
|
521
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
522
|
+
raise "Not a spawned agent" unless agent.spawn_info
|
|
523
|
+
raise "No agent spawner available" unless @agent_spawner
|
|
524
|
+
|
|
525
|
+
spawner_name = agent.spawn_info[:spawner_name]
|
|
526
|
+
@agent_spawner.terminate(agent_id)
|
|
527
|
+
|
|
528
|
+
dispatch_notification(
|
|
529
|
+
event: {event_type: :agent_terminated, monitor_type: :spawner, monitor_name: spawner_name || :system},
|
|
530
|
+
agent_id:,
|
|
531
|
+
message: "Agent #{agent_id} manually terminated"
|
|
532
|
+
)
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def cmd_signal_goal(req)
|
|
537
|
+
agent_id = req[:agent_id]
|
|
538
|
+
status = req[:status]&.to_sym
|
|
539
|
+
raise "Missing agent_id" unless agent_id
|
|
540
|
+
raise "Missing status" unless status
|
|
541
|
+
raise "Invalid status: #{status}" unless %i[completed failed errored in_progress].include?(status)
|
|
542
|
+
|
|
543
|
+
agent = @store.get(agent_id)
|
|
544
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
545
|
+
|
|
546
|
+
agent.set_goal_status(status)
|
|
547
|
+
agent.set_goal_summary(req[:summary]) if req[:summary]
|
|
548
|
+
@supervisor.signal_goal(agent_id, status)
|
|
549
|
+
|
|
550
|
+
# Fire agent_blocked notification when AI signals errored status
|
|
551
|
+
if status == :errored && agent.spawn_info
|
|
552
|
+
spawner_name = agent.spawn_info[:spawner_name]
|
|
553
|
+
dispatch_notification(
|
|
554
|
+
event: {event_type: :agent_blocked, monitor_type: :spawner, monitor_name: spawner_name || :system},
|
|
555
|
+
agent_id:,
|
|
556
|
+
message: "Agent #{agent_id} needs help (signaled errored)"
|
|
557
|
+
)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
Superkick.logger.info("control_server") { "Agent #{agent_id} signaled: #{status}" }
|
|
561
|
+
nil
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# -- Approval gates ------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
def cmd_list_approvals(_req)
|
|
567
|
+
raise "No approval store available" unless @approval_store
|
|
568
|
+
|
|
569
|
+
approvals = @approval_store.list.map do |entry|
|
|
570
|
+
{
|
|
571
|
+
id: entry[:id],
|
|
572
|
+
agent_id: entry[:agent_id],
|
|
573
|
+
spawner_name: entry[:spawner_config][:name],
|
|
574
|
+
event_type: entry[:event][:event_type].to_s,
|
|
575
|
+
created_at: entry[:created_at]
|
|
576
|
+
}
|
|
577
|
+
end
|
|
578
|
+
{approvals:}
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def cmd_approve(req)
|
|
582
|
+
approval_id = req[:approval_id]
|
|
583
|
+
raise "Missing approval_id" unless approval_id
|
|
584
|
+
raise "No approval store available" unless @approval_store
|
|
585
|
+
raise "No agent spawner available" unless @agent_spawner
|
|
586
|
+
|
|
587
|
+
entry = @approval_store.take(approval_id)
|
|
588
|
+
raise "No pending approval: #{approval_id}" unless entry
|
|
589
|
+
|
|
590
|
+
result = @agent_spawner.spawn(event: entry[:event], spawner_config: entry[:spawner_config])
|
|
591
|
+
{status: result[:status], agent_id: result[:agent_id]}
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def cmd_reject(req)
|
|
595
|
+
approval_id = req[:approval_id]
|
|
596
|
+
raise "Missing approval_id" unless approval_id
|
|
597
|
+
raise "No approval store available" unless @approval_store
|
|
598
|
+
|
|
599
|
+
reason = req[:reason]
|
|
600
|
+
entry = @approval_store.reject(approval_id, reason:)
|
|
601
|
+
raise "No pending approval: #{approval_id}" unless entry
|
|
602
|
+
|
|
603
|
+
detail = reason ? " (#{reason})" : ""
|
|
604
|
+
Superkick.logger.info("control_server") { "Rejected spawn approval: #{approval_id}#{detail}" }
|
|
605
|
+
nil
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def cmd_clear_rejection(req)
|
|
609
|
+
approval_id = req[:approval_id]
|
|
610
|
+
raise "Missing approval_id" unless approval_id
|
|
611
|
+
raise "No approval store available" unless @approval_store
|
|
612
|
+
|
|
613
|
+
@approval_store.clear_rejection(approval_id)
|
|
614
|
+
Superkick.logger.info("control_server") { "Cleared rejection: #{approval_id}" }
|
|
615
|
+
nil
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def cmd_clear_all_rejections(_req)
|
|
619
|
+
raise "No approval store available" unless @approval_store
|
|
620
|
+
|
|
621
|
+
@approval_store.clear_all_rejections
|
|
622
|
+
Superkick.logger.info("control_server") { "Cleared all rejections" }
|
|
623
|
+
nil
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def cmd_list_rejections(_req)
|
|
627
|
+
raise "No approval store available" unless @approval_store
|
|
628
|
+
|
|
629
|
+
{rejections: @approval_store.rejections}
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# -- Cost tracking -------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
def cmd_report_cost(req)
|
|
635
|
+
agent = @store.get(req[:agent_id])
|
|
636
|
+
raise "Agent not found" unless agent
|
|
637
|
+
|
|
638
|
+
source = req[:source]&.to_sym || :unknown
|
|
639
|
+
|
|
640
|
+
if source == :mcp_report
|
|
641
|
+
# MCP reports cumulative totals — compute delta
|
|
642
|
+
prev = agent.cost.to_h
|
|
643
|
+
delta_in = (req[:tokens_in] || 0) - prev[:total_tokens_in]
|
|
644
|
+
delta_out = (req[:tokens_out] || 0) - prev[:total_tokens_out]
|
|
645
|
+
delta_usd = (req[:cost_usd] || 0.0) - prev[:total_cost_usd]
|
|
646
|
+
|
|
647
|
+
# Only record positive deltas (avoid negative corrections)
|
|
648
|
+
agent.cost.record(
|
|
649
|
+
tokens_in: [delta_in, 0].max,
|
|
650
|
+
tokens_out: [delta_out, 0].max,
|
|
651
|
+
cost_usd: [delta_usd, 0.0].max,
|
|
652
|
+
source:
|
|
653
|
+
)
|
|
654
|
+
else
|
|
655
|
+
# PTY scrape reports incremental samples
|
|
656
|
+
agent.cost.record(
|
|
657
|
+
tokens_in: req[:tokens_in] || 0,
|
|
658
|
+
tokens_out: req[:tokens_out] || 0,
|
|
659
|
+
cost_usd: req[:cost_usd] || 0.0,
|
|
660
|
+
source:
|
|
661
|
+
)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
check_budget(agent)
|
|
665
|
+
nil
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def cmd_get_agent_cost(req)
|
|
669
|
+
agent = @store.get(req[:agent_id])
|
|
670
|
+
raise "Agent not found: #{req[:agent_id]}" unless agent
|
|
671
|
+
|
|
672
|
+
agent.cost.to_h
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def cmd_get_cost_summary(_req)
|
|
676
|
+
agents = []
|
|
677
|
+
@store.each do |agent|
|
|
678
|
+
cost_data = agent.cost.to_h
|
|
679
|
+
next if cost_data[:total_tokens_in] == 0 && cost_data[:total_cost_usd] == 0
|
|
680
|
+
|
|
681
|
+
entry = {agent_id: agent.id, cost: cost_data}
|
|
682
|
+
entry[:spawner_name] = agent.spawn_info[:spawner_name] if agent.spawn_info
|
|
683
|
+
entry[:goal_status] = agent.goal_status if agent.goal_status
|
|
684
|
+
agents << entry
|
|
685
|
+
end
|
|
686
|
+
{agents:}
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# -- Team operations -----------------------------------------------------
|
|
690
|
+
|
|
691
|
+
def cmd_post_update(req)
|
|
692
|
+
agent_id = req[:agent_id]
|
|
693
|
+
agent = @store.get(agent_id)
|
|
694
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
695
|
+
|
|
696
|
+
if req[:target_agent_id]
|
|
697
|
+
# Directed message — validate same team, inject into target
|
|
698
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
699
|
+
|
|
700
|
+
target = @store.get(req[:target_agent_id])
|
|
701
|
+
raise "Target not found: #{req[:target_agent_id]}" unless target
|
|
702
|
+
raise "Target is not on the same team" unless target.team_id == agent.team_id
|
|
703
|
+
|
|
704
|
+
injection_event = {
|
|
705
|
+
event_type: :teammate_message,
|
|
706
|
+
monitor_type: :team_log,
|
|
707
|
+
monitor_name: :team_log,
|
|
708
|
+
sender_agent_id: agent_id,
|
|
709
|
+
sender_role: agent.team_role.to_s,
|
|
710
|
+
message: req[:message],
|
|
711
|
+
time: Time.now.strftime("%H:%M:%S"),
|
|
712
|
+
injection_priority: :high,
|
|
713
|
+
injection_ttl: 900
|
|
714
|
+
}
|
|
715
|
+
@injector.inject(agent_id: req[:target_agent_id], event: injection_event)
|
|
716
|
+
|
|
717
|
+
dispatch_notification(
|
|
718
|
+
event: {event_type: :teammate_message, monitor_type: :team, monitor_name: :team,
|
|
719
|
+
target_agent_id: req[:target_agent_id], team_id: agent.team_id},
|
|
720
|
+
agent_id:,
|
|
721
|
+
message: "#{agent_id} sent message to #{req[:target_agent_id]}: #{req[:message].slice(0, 100)}"
|
|
722
|
+
)
|
|
723
|
+
else
|
|
724
|
+
# Broadcast update
|
|
725
|
+
event_type = (req[:kind]&.to_sym == :blocker) ? :teammate_blocker : :agent_update
|
|
726
|
+
dispatch_notification(
|
|
727
|
+
event: {event_type:, monitor_type: :agent, monitor_name: :agent,
|
|
728
|
+
kind: req[:kind]&.to_sym, team_id: agent.team_id},
|
|
729
|
+
agent_id:,
|
|
730
|
+
message: req[:message]
|
|
731
|
+
)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
nil
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def cmd_post_team_message(req)
|
|
738
|
+
team_id = req[:team_id]
|
|
739
|
+
raise "Missing team_id" unless team_id
|
|
740
|
+
raise "Missing message" unless req[:message]
|
|
741
|
+
|
|
742
|
+
members = @store.team(team_id)
|
|
743
|
+
raise "No agents found for team: #{team_id}" if members.empty?
|
|
744
|
+
|
|
745
|
+
# Inject the message into every agent on the team
|
|
746
|
+
members.each do |agent|
|
|
747
|
+
injection_event = {
|
|
748
|
+
event_type: :teammate_message,
|
|
749
|
+
monitor_type: :team_log,
|
|
750
|
+
monitor_name: :team_log,
|
|
751
|
+
sender_agent_id: "operator",
|
|
752
|
+
sender_role: "operator",
|
|
753
|
+
message: req[:message],
|
|
754
|
+
time: Time.now.strftime("%H:%M:%S"),
|
|
755
|
+
injection_priority: :high,
|
|
756
|
+
injection_ttl: 900
|
|
757
|
+
}
|
|
758
|
+
@injector.inject(agent_id: agent.id, event: injection_event)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Write to team log so it's visible in `team watch`
|
|
762
|
+
if @team_log_store
|
|
763
|
+
log = @team_log_store.get(team_id)
|
|
764
|
+
log.append(
|
|
765
|
+
agent_id: "operator",
|
|
766
|
+
agent_role: :operator,
|
|
767
|
+
category: :message,
|
|
768
|
+
message: req[:message]
|
|
769
|
+
)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
{delivered_to: members.size}
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def cmd_team_status(req)
|
|
776
|
+
team_id = req[:team_id]
|
|
777
|
+
unless team_id
|
|
778
|
+
agent_id = req[:agent_id]
|
|
779
|
+
agent = @store.get(agent_id)
|
|
780
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
781
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
782
|
+
team_id = agent.team_id
|
|
783
|
+
end
|
|
784
|
+
raise "No team log store available" unless @team_log_store
|
|
785
|
+
log = @team_log_store.get(team_id)
|
|
786
|
+
|
|
787
|
+
teammates = @store.team(team_id).map do |a|
|
|
788
|
+
entry = {agent_id: a.id, team_role: a.team_role, goal_status: a.goal_status}
|
|
789
|
+
entry[:role] = a.role if a.role
|
|
790
|
+
entry
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
result = {team_id:, teammates:}
|
|
794
|
+
|
|
795
|
+
if log
|
|
796
|
+
if req[:full_log]
|
|
797
|
+
result[:entries] = log.entries(since: req[:since]).map(&:to_h)
|
|
798
|
+
else
|
|
799
|
+
summary = log.summary
|
|
800
|
+
result[:latest] = summary[:latest_by_agent].map(&:to_h)
|
|
801
|
+
result[:unresolved_blockers] = summary[:unresolved_blockers].map(&:to_h)
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
result
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def cmd_list_teammates(req)
|
|
809
|
+
agent_id = req[:agent_id]
|
|
810
|
+
agent = @store.get(agent_id)
|
|
811
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
812
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
813
|
+
|
|
814
|
+
teammates = @store.team(agent.team_id).map do |a|
|
|
815
|
+
entry = {agent_id: a.id, team_role: a.team_role, goal_status: a.goal_status}
|
|
816
|
+
entry[:role] = a.role if a.role
|
|
817
|
+
entry[:claimed_at] = a.claimed_at if a.claimed_at
|
|
818
|
+
|
|
819
|
+
if @team_log_store
|
|
820
|
+
log = @team_log_store.get(agent.team_id)
|
|
821
|
+
if log
|
|
822
|
+
latest = log.entries.select { it.agent_id == a.id }.last
|
|
823
|
+
entry[:latest_update] = latest.to_h if latest
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
entry
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
{team_id: agent.team_id, teammates:}
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def cmd_spawn_worker(req)
|
|
834
|
+
agent_id = req[:agent_id]
|
|
835
|
+
raise "Missing agent_id" unless agent_id
|
|
836
|
+
raise "No agent spawner available" unless @agent_spawner
|
|
837
|
+
|
|
838
|
+
agent = @store.get(agent_id)
|
|
839
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
840
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
841
|
+
|
|
842
|
+
team_id = agent.team_id
|
|
843
|
+
repository_name = req[:repository]
|
|
844
|
+
task = req[:task]
|
|
845
|
+
agent_suffix = req[:agent_suffix]
|
|
846
|
+
raise "Missing repository" unless repository_name
|
|
847
|
+
raise "Missing task" unless task
|
|
848
|
+
raise "Missing agent_suffix" unless agent_suffix
|
|
849
|
+
|
|
850
|
+
# Enforce max_workers_per_team
|
|
851
|
+
current_workers = @store.team(team_id).count { it.team_role == :worker }
|
|
852
|
+
max_workers = @config.max_workers_per_team
|
|
853
|
+
raise "Team worker limit reached (#{max_workers})" if current_workers >= max_workers
|
|
854
|
+
|
|
855
|
+
# Validate repository exists in source
|
|
856
|
+
repository = @config.repository_source.find_by_name(repository_name)
|
|
857
|
+
raise "Unknown repository: #{repository_name}" unless repository
|
|
858
|
+
|
|
859
|
+
worker_id = "#{team_id}-#{agent_suffix}"
|
|
860
|
+
raise "Agent #{worker_id} already exists" if @store.has?(worker_id)
|
|
861
|
+
|
|
862
|
+
# Build worker event
|
|
863
|
+
goal_config = req[:goal] || {type: :agent_signal}
|
|
864
|
+
goal_config[:type] = goal_config[:type].to_sym if goal_config[:type]
|
|
865
|
+
|
|
866
|
+
worker_event = {
|
|
867
|
+
event_type: :worker_spawned,
|
|
868
|
+
agent_id: worker_id,
|
|
869
|
+
team_id:,
|
|
870
|
+
team_role: :worker,
|
|
871
|
+
role: req[:role],
|
|
872
|
+
lead_agent_id: agent_id,
|
|
873
|
+
repository_name: repository_name.to_s,
|
|
874
|
+
task:,
|
|
875
|
+
depends_on: req[:depends_on]
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
# Auto-attach team_log monitor; lead-provided monitors merge on top
|
|
879
|
+
auto_monitors = {team_log: {type: :team_log, team_id:}}
|
|
880
|
+
explicit_monitors = req[:monitors] || {}
|
|
881
|
+
worker_event[:_spawn_monitors] = auto_monitors.merge(explicit_monitors)
|
|
882
|
+
|
|
883
|
+
# Forward notifiers from the lead
|
|
884
|
+
worker_event[:_spawn_notifiers] = req[:notifiers] if req[:notifiers]&.any?
|
|
885
|
+
|
|
886
|
+
# Add monitor names to template context (names only, no config/secrets)
|
|
887
|
+
all_monitor_names = worker_event[:_spawn_monitors].keys.map(&:to_s)
|
|
888
|
+
worker_event[:monitor_names] = all_monitor_names
|
|
889
|
+
|
|
890
|
+
# Determine spawner config for the worker
|
|
891
|
+
spawner_config = build_worker_spawner_config(agent, repository, goal_config)
|
|
892
|
+
|
|
893
|
+
result = @agent_spawner.spawn(event: worker_event, spawner_config:)
|
|
894
|
+
|
|
895
|
+
if result[:status] == :spawned
|
|
896
|
+
dispatch_notification(
|
|
897
|
+
event: {event_type: :worker_spawned, monitor_type: :team, monitor_name: :team,
|
|
898
|
+
team_id:, repository_name:},
|
|
899
|
+
agent_id: worker_id,
|
|
900
|
+
message: "Worker #{worker_id} spawned for repository #{repository_name} by #{agent_id}"
|
|
901
|
+
)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
{status: result[:status], agent_id: worker_id}
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def cmd_spawn_worker_by_team(req)
|
|
908
|
+
team_id = req[:team_id]
|
|
909
|
+
raise "Missing team_id" unless team_id
|
|
910
|
+
raise "No agent spawner available" unless @agent_spawner
|
|
911
|
+
|
|
912
|
+
# Find the team lead (or any team member) to derive spawner config
|
|
913
|
+
team_agents = @store.team(team_id)
|
|
914
|
+
raise "No agents found for team: #{team_id}" if team_agents.empty?
|
|
915
|
+
lead_agent = team_agents.find { it.team_role == :lead } || team_agents.first
|
|
916
|
+
|
|
917
|
+
repository_name = req[:repository]
|
|
918
|
+
task = req[:task]
|
|
919
|
+
raise "Missing repository" unless repository_name
|
|
920
|
+
raise "Missing task" unless task
|
|
921
|
+
|
|
922
|
+
# Enforce max_workers_per_team
|
|
923
|
+
current_workers = team_agents.count { it.team_role == :worker }
|
|
924
|
+
max_workers = @config.max_workers_per_team
|
|
925
|
+
raise "Team worker limit reached (#{max_workers})" if current_workers >= max_workers
|
|
926
|
+
|
|
927
|
+
# Validate repository exists in source
|
|
928
|
+
repository = @config.repository_source.find_by_name(repository_name)
|
|
929
|
+
raise "Unknown repository: #{repository_name}" unless repository
|
|
930
|
+
|
|
931
|
+
# Generate agent ID from role + random suffix
|
|
932
|
+
worker_id = generate_worker_id(team_id, req[:role])
|
|
933
|
+
raise "Agent #{worker_id} already exists" if @store.has?(worker_id)
|
|
934
|
+
|
|
935
|
+
# Build worker event
|
|
936
|
+
goal_config = req[:goal] || {type: :agent_signal}
|
|
937
|
+
goal_config[:type] = goal_config[:type].to_sym if goal_config[:type]
|
|
938
|
+
|
|
939
|
+
worker_event = {
|
|
940
|
+
event_type: :worker_spawned,
|
|
941
|
+
agent_id: worker_id,
|
|
942
|
+
team_id:,
|
|
943
|
+
team_role: :worker,
|
|
944
|
+
role: req[:role],
|
|
945
|
+
lead_agent_id: lead_agent.id,
|
|
946
|
+
repository_name: repository_name.to_s,
|
|
947
|
+
task:,
|
|
948
|
+
depends_on: req[:depends_on]
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
# Auto-attach team_log monitor; merge user-provided monitors on top
|
|
952
|
+
spawn_monitors = {team_log: {type: :team_log, team_id:}}
|
|
953
|
+
if req[:monitors].is_a?(Hash)
|
|
954
|
+
req[:monitors].each { |k, v| spawn_monitors[k.to_sym] = v }
|
|
955
|
+
end
|
|
956
|
+
worker_event[:_spawn_monitors] = spawn_monitors
|
|
957
|
+
|
|
958
|
+
# Attach user-provided notifiers
|
|
959
|
+
if req[:notifiers].is_a?(Hash)
|
|
960
|
+
spawn_notifiers = {}
|
|
961
|
+
req[:notifiers].each { |k, v| spawn_notifiers[k.to_sym] = v }
|
|
962
|
+
worker_event[:_spawn_notifiers] = spawn_notifiers
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
spawner_config = build_worker_spawner_config(lead_agent, repository, goal_config)
|
|
966
|
+
result = @agent_spawner.spawn(event: worker_event, spawner_config:)
|
|
967
|
+
|
|
968
|
+
if result[:status] == :spawned
|
|
969
|
+
dispatch_notification(
|
|
970
|
+
event: {event_type: :worker_spawned, monitor_type: :team, monitor_name: :team,
|
|
971
|
+
team_id:, repository_name:},
|
|
972
|
+
agent_id: worker_id,
|
|
973
|
+
message: "Worker #{worker_id} spawned for repository #{repository_name} via CLI"
|
|
974
|
+
)
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
{status: result[:status], agent_id: worker_id}
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# -- Repository discovery ------------------------------------------------
|
|
981
|
+
|
|
982
|
+
def cmd_discover_repositories(_req)
|
|
983
|
+
source = @config.repository_source
|
|
984
|
+
repositories = source.repositories.map do |name, repository|
|
|
985
|
+
entry = {name: name.to_s}
|
|
986
|
+
entry[:dependencies] = repository.dependencies if repository.dependencies.any?
|
|
987
|
+
entry[:path] = repository.path if repository.path
|
|
988
|
+
entry[:url] = repository.url if repository.url
|
|
989
|
+
entry[:version_control] = repository.version_control if repository.version_control
|
|
990
|
+
entry
|
|
991
|
+
end
|
|
992
|
+
{repositories:}
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
# -- Team artifacts ------------------------------------------------------
|
|
996
|
+
|
|
997
|
+
def cmd_publish_artifact(req)
|
|
998
|
+
agent_id = req[:agent_id]
|
|
999
|
+
agent = @store.get(agent_id)
|
|
1000
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
1001
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
1002
|
+
raise "No artifact store available" unless @team_artifact_store
|
|
1003
|
+
raise "Missing name" unless req[:name]
|
|
1004
|
+
raise "Missing content" unless req[:content]
|
|
1005
|
+
|
|
1006
|
+
artifact = @team_artifact_store.publish(
|
|
1007
|
+
team_id: agent.team_id,
|
|
1008
|
+
author: agent_id,
|
|
1009
|
+
name: req[:name],
|
|
1010
|
+
content: req[:content]
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
dispatch_notification(
|
|
1014
|
+
event: {event_type: :artifact_published, monitor_type: :agent, monitor_name: :agent,
|
|
1015
|
+
artifact_name: req[:name], team_id: agent.team_id},
|
|
1016
|
+
agent_id:,
|
|
1017
|
+
message: "Published artifact: #{req[:name]}"
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
{name: artifact.name, author: artifact.author, updated_at: artifact.updated_at}
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def cmd_read_artifact(req)
|
|
1024
|
+
agent_id = req[:agent_id]
|
|
1025
|
+
agent = @store.get(agent_id)
|
|
1026
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
1027
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
1028
|
+
raise "No artifact store available" unless @team_artifact_store
|
|
1029
|
+
raise "Missing author" unless req[:author]
|
|
1030
|
+
raise "Missing name" unless req[:name]
|
|
1031
|
+
|
|
1032
|
+
artifact = @team_artifact_store.get(
|
|
1033
|
+
team_id: agent.team_id,
|
|
1034
|
+
author: req[:author],
|
|
1035
|
+
name: req[:name]
|
|
1036
|
+
)
|
|
1037
|
+
raise "Artifact not found: #{req[:author]}/#{req[:name]}" unless artifact
|
|
1038
|
+
|
|
1039
|
+
{name: artifact.name, author: artifact.author, content: artifact.content,
|
|
1040
|
+
created_at: artifact.created_at, updated_at: artifact.updated_at}
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def cmd_list_artifacts(req)
|
|
1044
|
+
agent_id = req[:agent_id]
|
|
1045
|
+
agent = @store.get(agent_id)
|
|
1046
|
+
raise "Agent not found: #{agent_id}" unless agent
|
|
1047
|
+
raise "Agent is not on a team" unless agent.team_id
|
|
1048
|
+
raise "No artifact store available" unless @team_artifact_store
|
|
1049
|
+
|
|
1050
|
+
artifacts = @team_artifact_store.list(
|
|
1051
|
+
team_id: agent.team_id,
|
|
1052
|
+
author: req[:author]
|
|
1053
|
+
)
|
|
1054
|
+
{artifacts:}
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# -- Team artifacts (by team_id, for CLI) ---------------------------------
|
|
1058
|
+
|
|
1059
|
+
def cmd_list_team_artifacts(req)
|
|
1060
|
+
team_id = req[:team_id]
|
|
1061
|
+
raise "Missing team_id" unless team_id
|
|
1062
|
+
raise "No artifact store available" unless @team_artifact_store
|
|
1063
|
+
|
|
1064
|
+
artifacts = @team_artifact_store.list(
|
|
1065
|
+
team_id:,
|
|
1066
|
+
author: req[:author]
|
|
1067
|
+
)
|
|
1068
|
+
{artifacts:}
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def cmd_read_team_artifact(req)
|
|
1072
|
+
team_id = req[:team_id]
|
|
1073
|
+
raise "Missing team_id" unless team_id
|
|
1074
|
+
raise "No artifact store available" unless @team_artifact_store
|
|
1075
|
+
raise "Missing author" unless req[:author]
|
|
1076
|
+
raise "Missing name" unless req[:name]
|
|
1077
|
+
|
|
1078
|
+
artifact = @team_artifact_store.get(
|
|
1079
|
+
team_id:,
|
|
1080
|
+
author: req[:author],
|
|
1081
|
+
name: req[:name]
|
|
1082
|
+
)
|
|
1083
|
+
raise "Artifact not found: #{req[:author]}/#{req[:name]}" unless artifact
|
|
1084
|
+
|
|
1085
|
+
{name: artifact.name, author: artifact.author, content: artifact.content,
|
|
1086
|
+
created_at: artifact.created_at, updated_at: artifact.updated_at}
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# -- Privileged monitor queries ------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
def cmd_get_privileged_monitors(_req)
|
|
1092
|
+
{
|
|
1093
|
+
names: @config.monitors.select { |_, config| config.is_a?(Hash) && config[:privileged] == true }.keys,
|
|
1094
|
+
types: @config.privileged_types
|
|
1095
|
+
}
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def serialize_agent(agent)
|
|
1099
|
+
visible = agent.monitors.reject { |name, config| monitor_hidden?(name, config) }
|
|
1100
|
+
hash = {
|
|
1101
|
+
agent_id: agent.id,
|
|
1102
|
+
registered_at: agent.registered_at,
|
|
1103
|
+
last_notified: agent.last_notified_at,
|
|
1104
|
+
monitor_count: visible.size,
|
|
1105
|
+
has_buffer: !agent.buffer_socket_path.nil?,
|
|
1106
|
+
has_output_log: !agent.output_log_path.nil?,
|
|
1107
|
+
has_attach: !agent.attach_socket_path.nil?,
|
|
1108
|
+
output_log_path: agent.output_log_path,
|
|
1109
|
+
recording_path: agent.recording_path
|
|
1110
|
+
}
|
|
1111
|
+
hash[:spawn_info] = agent.spawn_info if agent.spawn_info
|
|
1112
|
+
hash[:goal_status] = agent.goal_status if agent.goal_status
|
|
1113
|
+
hash[:claimed_at] = agent.claimed_at if agent.claimed_at
|
|
1114
|
+
hash[:team_id] = agent.team_id if agent.team_id
|
|
1115
|
+
hash[:team_role] = agent.team_role if agent.team_role
|
|
1116
|
+
hash[:role] = agent.role if agent.role
|
|
1117
|
+
cost_data = agent.cost.to_h
|
|
1118
|
+
hash[:cost] = cost_data if cost_data[:total_tokens_in] > 0 || cost_data[:total_cost_usd] > 0
|
|
1119
|
+
hash
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# Returns true when a monitor should be hidden from AI-facing responses.
|
|
1123
|
+
def monitor_hidden?(name, config)
|
|
1124
|
+
monitor_type = config.is_a?(Hash) ? (config[:type] || name) : name
|
|
1125
|
+
@config.monitor_privileged?(name) || @config.type_privileged?(monitor_type)
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def monitor_type_hidden?(type)
|
|
1129
|
+
@config.type_privileged?(type)
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def collect_environment_actions
|
|
1133
|
+
Monitor.all_environment_actions
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def filter_privileged_detected(detected)
|
|
1137
|
+
priv_names = @config.monitors.select { |_, config| config.is_a?(Hash) && config[:privileged] == true }.keys
|
|
1138
|
+
priv_types = @config.privileged_types || []
|
|
1139
|
+
detected.reject do |name, config|
|
|
1140
|
+
monitor_type = config.is_a?(Hash) ? (config[:type] || name) : name
|
|
1141
|
+
priv_names.include?(name) || priv_types.include?(monitor_type.to_sym)
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def raise_if_privileged!(name, type, action:)
|
|
1146
|
+
configuration = @config
|
|
1147
|
+
|
|
1148
|
+
if configuration.monitor_privileged?(name)
|
|
1149
|
+
raise "Cannot #{action} monitor '#{name}': it is a privileged monitor (protected by name)"
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
if configuration.type_privileged?(type)
|
|
1153
|
+
raise "Cannot #{action} monitor '#{name}' (type '#{type}'): monitors of type '#{type}' are privileged (protected by type)"
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
# Returns true when a notifier should be hidden from AI-facing responses.
|
|
1158
|
+
def notifier_hidden?(name, config)
|
|
1159
|
+
notifier_type = config.is_a?(Hash) ? (config[:type] || name) : name
|
|
1160
|
+
@config.notifier_privileged?(name) || @config.notifier_type_privileged?(notifier_type)
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def raise_if_notifier_privileged!(name, type, action:)
|
|
1164
|
+
configuration = @config
|
|
1165
|
+
|
|
1166
|
+
if configuration.notifier_privileged?(name)
|
|
1167
|
+
raise "Cannot #{action} notifier '#{name}': it is a privileged notifier (protected by name)"
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
if configuration.notifier_type_privileged?(type)
|
|
1171
|
+
raise "Cannot #{action} notifier '#{name}' (type '#{type}'): notifiers of type '#{type}' are privileged (protected by type)"
|
|
1172
|
+
end
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def check_budget(agent)
|
|
1176
|
+
return unless @budget_checker
|
|
1177
|
+
|
|
1178
|
+
violations = @budget_checker.check(agent)
|
|
1179
|
+
return if violations.empty?
|
|
1180
|
+
|
|
1181
|
+
violations.each do |v|
|
|
1182
|
+
case v[:action]
|
|
1183
|
+
when :warning
|
|
1184
|
+
notify_budget(:budget_warning, agent, v)
|
|
1185
|
+
when :exceeded
|
|
1186
|
+
notify_budget(:budget_exceeded, agent, v)
|
|
1187
|
+
enforce_budget(agent, v) if hard_enforcement?(agent)
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
def notify_budget(event_type, agent, violation)
|
|
1193
|
+
dispatch_notification(
|
|
1194
|
+
event: {
|
|
1195
|
+
event_type:,
|
|
1196
|
+
monitor_type: :cost,
|
|
1197
|
+
monitor_name: :budget,
|
|
1198
|
+
budget: violation[:budget],
|
|
1199
|
+
spent: violation[:spent].round(2)
|
|
1200
|
+
},
|
|
1201
|
+
agent_id: agent.id,
|
|
1202
|
+
message: "Agent #{agent.id} #{violation[:level]} budget: " \
|
|
1203
|
+
"$#{violation[:spent].round(2)} / $#{violation[:budget]} " \
|
|
1204
|
+
"(#{violation[:action]})"
|
|
1205
|
+
)
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
def hard_enforcement?(agent)
|
|
1209
|
+
return false unless agent.spawn_info
|
|
1210
|
+
spawner_name = agent.spawn_info[:spawner_name]&.to_sym
|
|
1211
|
+
@config.spawners.dig(spawner_name, :budget, :enforce)&.to_s == "hard"
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
def enforce_budget(agent, violation)
|
|
1215
|
+
case violation[:level]
|
|
1216
|
+
when :agent
|
|
1217
|
+
@agent_spawner&.terminate(agent.id)
|
|
1218
|
+
when :spawner
|
|
1219
|
+
spawner_name = agent.spawn_info[:spawner_name]&.to_sym
|
|
1220
|
+
@supervisor.stop_spawner(spawner_name) if spawner_name
|
|
1221
|
+
when :global
|
|
1222
|
+
@config.spawners.each_key { @supervisor.stop_spawner(it) }
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def build_worker_spawner_config(lead_agent, repository, goal_config)
|
|
1227
|
+
spawner_name = lead_agent.spawn_info&.dig(:spawner_name)
|
|
1228
|
+
spawner_cfg = spawner_name ? (@config.spawners[spawner_name.to_sym] || {}) : {}
|
|
1229
|
+
worker_cfg = spawner_cfg.dig(:team, :worker) || {}
|
|
1230
|
+
|
|
1231
|
+
# Resolve driver: team.worker.driver merged on top of spawner driver
|
|
1232
|
+
driver = Driver.merge_driver(spawner_cfg[:driver], worker_cfg[:driver])
|
|
1233
|
+
max_duration = worker_cfg[:max_duration] || spawner_cfg[:max_duration]
|
|
1234
|
+
|
|
1235
|
+
config = {
|
|
1236
|
+
name: spawner_name,
|
|
1237
|
+
driver:,
|
|
1238
|
+
repository: repository.name.to_s,
|
|
1239
|
+
goal: goal_config,
|
|
1240
|
+
prompt_template: "team/worker_kickoff"
|
|
1241
|
+
}
|
|
1242
|
+
config[:max_duration] = max_duration if max_duration
|
|
1243
|
+
config[:budget] = worker_cfg[:budget] if worker_cfg[:budget]
|
|
1244
|
+
config
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
# Forward a buffer command to the agent via the buffer client.
|
|
1248
|
+
def proxy_buffer(agent_id, command, **extra_params)
|
|
1249
|
+
response = @buffer_client.request(agent_id, command, **extra_params.compact)
|
|
1250
|
+
raise response[:error] if response && response[:ok] == false && response[:error]
|
|
1251
|
+
|
|
1252
|
+
response
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def generate_worker_id(team_id, role)
|
|
1256
|
+
suffix = if role
|
|
1257
|
+
slugified = role.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")[0, 20]
|
|
1258
|
+
random = SecureRandom.hex(3)
|
|
1259
|
+
"#{slugified}-#{random}"
|
|
1260
|
+
else
|
|
1261
|
+
SecureRandom.hex(3)
|
|
1262
|
+
end
|
|
1263
|
+
"#{team_id}-#{suffix}"
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def dispatch_notification(...)
|
|
1267
|
+
@notification_dispatcher.dispatch(...)
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
end
|
|
1271
|
+
end
|