agents_control 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/LICENSE +202 -0
- data/README.md +282 -0
- data/exe/agents_control +6 -0
- data/lib/agents_control/agents/base.rb +52 -0
- data/lib/agents_control/agents/claude_code.rb +243 -0
- data/lib/agents_control/anchors/scheduler.rb +161 -0
- data/lib/agents_control/channels/base.rb +27 -0
- data/lib/agents_control/channels/telegram/api.rb +179 -0
- data/lib/agents_control/channels/telegram/bot.rb +146 -0
- data/lib/agents_control/channels/telegram/channel.rb +251 -0
- data/lib/agents_control/channels/telegram/chunker.rb +64 -0
- data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
- data/lib/agents_control/channels/telegram/markdown.rb +59 -0
- data/lib/agents_control/channels/telegram/router.rb +482 -0
- data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
- data/lib/agents_control/cli.rb +347 -0
- data/lib/agents_control/config.rb +178 -0
- data/lib/agents_control/console.rb +320 -0
- data/lib/agents_control/daemon.rb +251 -0
- data/lib/agents_control/dispatcher.rb +163 -0
- data/lib/agents_control/doctor.rb +234 -0
- data/lib/agents_control/event.rb +111 -0
- data/lib/agents_control/executor.rb +83 -0
- data/lib/agents_control/hooks/server.rb +197 -0
- data/lib/agents_control/keyboard.rb +90 -0
- data/lib/agents_control/menu.rb +100 -0
- data/lib/agents_control/pending.rb +70 -0
- data/lib/agents_control/process_probe.rb +136 -0
- data/lib/agents_control/prompt.rb +227 -0
- data/lib/agents_control/rate_limit_watcher.rb +202 -0
- data/lib/agents_control/registry.rb +115 -0
- data/lib/agents_control/reply.rb +41 -0
- data/lib/agents_control/screen_watcher.rb +158 -0
- data/lib/agents_control/secrets.rb +256 -0
- data/lib/agents_control/service.rb +165 -0
- data/lib/agents_control/session.rb +66 -0
- data/lib/agents_control/store.rb +132 -0
- data/lib/agents_control/terminals/base.rb +76 -0
- data/lib/agents_control/terminals/iterm2.rb +167 -0
- data/lib/agents_control/terminals/null.rb +27 -0
- data/lib/agents_control/terminals/tmux.rb +106 -0
- data/lib/agents_control/transcript.rb +123 -0
- data/lib/agents_control/version.rb +5 -0
- data/lib/agents_control/which.rb +59 -0
- data/lib/agents_control.rb +52 -0
- metadata +102 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# Decides what to do with an agent event: stay quiet, notify, or ask.
|
|
5
|
+
#
|
|
6
|
+
# All the policy lives here, because it's the one part of the system
|
|
7
|
+
# people actually want to tune with settings. Agent adapters only turn
|
|
8
|
+
# a payload into an Event, a channel only displays it — Dispatcher does the thinking.
|
|
9
|
+
class Dispatcher
|
|
10
|
+
# The key distinction: is a human at the keyboard.
|
|
11
|
+
#
|
|
12
|
+
# While they are, intercepting permission requests is counterproductive:
|
|
13
|
+
# they'll answer in the terminal faster than they can reach for their
|
|
14
|
+
# phone, and a blocked hook keeps the dialog from ever reaching the
|
|
15
|
+
# screen at all. So by default this only notifies, and interception
|
|
16
|
+
# is turned on explicitly — with /away before stepping out.
|
|
17
|
+
def initialize(agents:, channel:, config:, pending: nil, logger: nil)
|
|
18
|
+
@agents = agents
|
|
19
|
+
@channel = channel
|
|
20
|
+
@config = config
|
|
21
|
+
@pending = pending || Pending.new
|
|
22
|
+
@logger = logger
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :pending
|
|
26
|
+
|
|
27
|
+
# Returns the hook response body. An empty hash means "no decision" —
|
|
28
|
+
# the agent behaves as it would without us.
|
|
29
|
+
def handle(payload)
|
|
30
|
+
agent = @agents.find { |candidate| candidate.class.handles?(payload) }
|
|
31
|
+
return {} unless agent
|
|
32
|
+
|
|
33
|
+
event = agent.to_event(payload)
|
|
34
|
+
return {} unless event
|
|
35
|
+
|
|
36
|
+
reply = decide(event)
|
|
37
|
+
return {} unless reply
|
|
38
|
+
|
|
39
|
+
agent.to_response(event, reply)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
log("failed to handle event: #{e.class}: #{e.message}")
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def decide(event)
|
|
48
|
+
unless event.question?
|
|
49
|
+
notify(event)
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# AskUserQuestion's answer is never delivered through the hook
|
|
54
|
+
# response — only allow/deny is. Blocking here would just hold the
|
|
55
|
+
# hook open for the full reply_timeout and then auto-deny, with
|
|
56
|
+
# nothing gained: the actual answer has to be typed into the
|
|
57
|
+
# terminal, which Channel does directly, unconditionally.
|
|
58
|
+
if event.ask_user_question?
|
|
59
|
+
notify(event)
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A human is at the keyboard — don't get in their way, just notify.
|
|
64
|
+
unless away?
|
|
65
|
+
notify(event)
|
|
66
|
+
return nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
automatic(event) || ask(event)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def away? = @config.get("answers.away", false)
|
|
73
|
+
|
|
74
|
+
# What can be decided without a human.
|
|
75
|
+
def automatic(event)
|
|
76
|
+
case event.kind
|
|
77
|
+
when :needs_permission then automatic_permission(event)
|
|
78
|
+
when :needs_input then automatic_input(event)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def automatic_permission(event)
|
|
83
|
+
return nil unless @config.get("answers.auto_approve_permissions", false)
|
|
84
|
+
# The forbidden list overrides any automatic setting: these things
|
|
85
|
+
# a human confirms in person, whatever the settings say.
|
|
86
|
+
return nil if forbidden?(event)
|
|
87
|
+
|
|
88
|
+
Reply.allow
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A "continue" reply is safe — it grants the agent no new
|
|
92
|
+
# permissions, it only clears the question "should I go on?" That's
|
|
93
|
+
# why it's on by default, unlike automatic tool approval.
|
|
94
|
+
def automatic_input(event)
|
|
95
|
+
return nil unless @config.get("answers.auto_continue", true)
|
|
96
|
+
return nil unless continuation?(event.text)
|
|
97
|
+
|
|
98
|
+
Reply.text("Continue.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
CONTINUATION = /(продолж|continue|proceed|shall i|идти дальше|go ahead)/i
|
|
102
|
+
|
|
103
|
+
# Signs that a human is being offered a choice, not asked permission to go on.
|
|
104
|
+
ALTERNATIVE = /(\bили\b|\bлибо\b|\bor\b|^\s*\d[.)]\s|\bвариант)/i
|
|
105
|
+
|
|
106
|
+
# A "should I continue?" question differs from a substantive one in
|
|
107
|
+
# having no subject — it's answerable without reading any context.
|
|
108
|
+
#
|
|
109
|
+
# The word "continue" alone isn't enough for this: "Should I continue
|
|
110
|
+
# the refactor, or show the plan first?" is a choice, and answering
|
|
111
|
+
# "continue" on the human's behalf there means making the decision
|
|
112
|
+
# for them. So the presence of an alternative overrides the
|
|
113
|
+
# automation, even when the word matches.
|
|
114
|
+
def continuation?(text)
|
|
115
|
+
value = text.to_s
|
|
116
|
+
return false if value.empty? || value.length > 300
|
|
117
|
+
return false if value.match?(ALTERNATIVE)
|
|
118
|
+
|
|
119
|
+
value.match?(CONTINUATION)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def forbidden?(event)
|
|
123
|
+
haystack = [event.tool_name, event.tool_input.to_s].join(" ").downcase
|
|
124
|
+
|
|
125
|
+
Array(@config.get("answers.never_auto_approve", [])).any? do |needle|
|
|
126
|
+
haystack.include?(needle.to_s.downcase)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ask(event)
|
|
131
|
+
timeout = @config.get("answers.reply_timeout", 600)
|
|
132
|
+
log("asking: #{event.label} — #{event.summary[0, 80]}")
|
|
133
|
+
|
|
134
|
+
reply = @channel.ask(event, pending: @pending, timeout: timeout)
|
|
135
|
+
|
|
136
|
+
# Silence is a refusal, not a permission. If nobody answered while
|
|
137
|
+
# the owner was out, the action doesn't happen.
|
|
138
|
+
log("no answer, declining: #{event.label}") if reply.none?
|
|
139
|
+
reply
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def notify(event)
|
|
143
|
+
return unless notifiable?(event)
|
|
144
|
+
|
|
145
|
+
log("notifying: #{event.label} — #{event.summary.to_s[0, 80]}")
|
|
146
|
+
@channel.notify(event)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
log("failed to notify: #{e.message}")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Not every event can make noise: an agent calls tools dozens of
|
|
152
|
+
# times per turn. Only report what a human actually needs to know about.
|
|
153
|
+
def notifiable?(event)
|
|
154
|
+
case event.kind
|
|
155
|
+
when :error then true
|
|
156
|
+
when :needs_permission, :needs_input then @config.get("answers.notify_when_present", true)
|
|
157
|
+
else false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def log(message) = @logger&.puts("[dispatcher] #{message}")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# Checks that everything is in place.
|
|
5
|
+
#
|
|
6
|
+
# Main principle: check the environment the daemon will actually get,
|
|
7
|
+
# not the one the user happens to be sitting in. An interactive shell
|
|
8
|
+
# can show a different Ruby, a different PATH, different environment
|
|
9
|
+
# variables than what a separately launched daemon process will see.
|
|
10
|
+
class Doctor
|
|
11
|
+
Check = Struct.new(:name, :status, :detail, :fix, keyword_init: true) do
|
|
12
|
+
def ok? = status == :ok
|
|
13
|
+
def failed? = status == :fail
|
|
14
|
+
def icon = { ok: "✓", warn: "!", fail: "✗" }[status]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# AppleScript's error when the user hasn't granted permission to
|
|
18
|
+
# control the app.
|
|
19
|
+
NOT_AUTHORISED = "-1743"
|
|
20
|
+
|
|
21
|
+
def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil)
|
|
22
|
+
@config = config || Config.load
|
|
23
|
+
@secrets = secrets || Secrets.new
|
|
24
|
+
@executor = executor
|
|
25
|
+
@api = api
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
CHECKS = %i[
|
|
29
|
+
ruby_check terminal_check automation_check agent_binary_check
|
|
30
|
+
token_check chats_check bot_check daemon_check hooks_check
|
|
31
|
+
anchors_check wake_check
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Checks run one at a time, each wrapped: a diagnostic tool has no
|
|
35
|
+
# business crashing. An unexpected error is just another result, not
|
|
36
|
+
# the end of the run — otherwise the first small thing would hide
|
|
37
|
+
# everything after it.
|
|
38
|
+
def run
|
|
39
|
+
CHECKS.filter_map do |name|
|
|
40
|
+
send(name)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Check.new(name: name.to_s.sub("_check", ""), status: :fail,
|
|
43
|
+
detail: "check failed: #{e.class}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def ok(name, detail) = Check.new(name: name, status: :ok, detail: detail)
|
|
50
|
+
def warn(name, detail, fix = nil) = Check.new(name: name, status: :warn, detail: detail, fix: fix)
|
|
51
|
+
def fail(name, detail, fix = nil) = Check.new(name: name, status: :fail, detail: detail, fix: fix)
|
|
52
|
+
|
|
53
|
+
# The plist gets RbConfig.ruby — the interpreter running this code,
|
|
54
|
+
# so it's guaranteed to work. What's checked isn't that, but what
|
|
55
|
+
# would be found by searching PATH: a version manager's broken shim
|
|
56
|
+
# can sit ahead of the real Ruby there.
|
|
57
|
+
def ruby_check
|
|
58
|
+
running = RbConfig.ruby
|
|
59
|
+
return fail("Ruby", "#{RUBY_VERSION} — needs 3.2+", "update the interpreter") if RUBY_VERSION < "3.2"
|
|
60
|
+
|
|
61
|
+
found = Which.find("ruby")
|
|
62
|
+
shadow = found && found != running && !working_ruby?(found)
|
|
63
|
+
|
|
64
|
+
return warn("Ruby", "#{RUBY_VERSION} — #{running}",
|
|
65
|
+
"a broken #{found} sits earlier on PATH; the service uses " \
|
|
66
|
+
"an absolute path, so this won't get in the way") if shadow
|
|
67
|
+
|
|
68
|
+
ok("Ruby", "#{RUBY_VERSION} — #{running}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def working_ruby?(path)
|
|
72
|
+
result = @executor.run(path, "-e", "print RUBY_VERSION")
|
|
73
|
+
|
|
74
|
+
result.success? && !result.stdout.strip.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def terminal_check
|
|
78
|
+
registry = Registry.new(executor: @executor)
|
|
79
|
+
names = registry.available_backends.map(&:name)
|
|
80
|
+
|
|
81
|
+
return warn("Terminal", "no backends available",
|
|
82
|
+
"install tmux or launch iTerm2 — without them only monitoring works") if names.empty?
|
|
83
|
+
|
|
84
|
+
ok("Terminal", names.join(", "))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Permission to control iTerm2 can't be granted programmatically: the
|
|
88
|
+
# TCC database is closed off by SIP, and that's a load-bearing part
|
|
89
|
+
# of macOS security, not an oversight. All that's possible is
|
|
90
|
+
# triggering the prompt at a clear moment, catching the refusal, and
|
|
91
|
+
# opening the right settings pane.
|
|
92
|
+
def automation_check
|
|
93
|
+
return nil unless macos?
|
|
94
|
+
return nil unless iterm_running?
|
|
95
|
+
|
|
96
|
+
result = @executor.run(Which.find("osascript"), "-e",
|
|
97
|
+
'tell application "iTerm2" to count windows')
|
|
98
|
+
|
|
99
|
+
return ok("iTerm2 permission", "granted") if result.success?
|
|
100
|
+
|
|
101
|
+
if result.stderr.include?(NOT_AUTHORISED)
|
|
102
|
+
fail("iTerm2 permission", "not granted",
|
|
103
|
+
'open "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"')
|
|
104
|
+
else
|
|
105
|
+
warn("iTerm2 permission", result.stderr.to_s[0, 80])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def agent_binary_check
|
|
110
|
+
path = Which.find("claude")
|
|
111
|
+
return warn("Claude Code", "binary not found", "needed for rate-limit anchors") unless path
|
|
112
|
+
|
|
113
|
+
ok("Claude Code", path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def token_check
|
|
117
|
+
source = @secrets.source_for(:telegram_token)
|
|
118
|
+
return fail("Token", "not found", "agents_control setup") unless source
|
|
119
|
+
|
|
120
|
+
insecure = source.respond_to?(:insecure?) && source.insecure?
|
|
121
|
+
return warn("Token", "#{source.name} — insecure storage") if insecure
|
|
122
|
+
|
|
123
|
+
ok("Token", source.name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def chats_check
|
|
127
|
+
chats = Array(@config.get("telegram.allowed_chat_ids", []))
|
|
128
|
+
return fail("Allowed chats", "list is empty — the bot won't answer anyone",
|
|
129
|
+
"agents_control setup") if chats.empty?
|
|
130
|
+
|
|
131
|
+
ok("Allowed chats", chats.join(", "))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
TOKEN_SHAPE = /\A\d+:[A-Za-z0-9_-]{30,}\z/
|
|
135
|
+
|
|
136
|
+
def bot_check
|
|
137
|
+
token = @secrets.get(:telegram_token)
|
|
138
|
+
return nil unless token
|
|
139
|
+
|
|
140
|
+
# The shape is checked before touching the network: unprintable
|
|
141
|
+
# characters would land straight in the URL and fail the request
|
|
142
|
+
# with a cryptic address-parsing error.
|
|
143
|
+
return fail("Bot", "token doesn't look like one issued by BotFather", "agents_control setup") unless
|
|
144
|
+
token.match?(TOKEN_SHAPE)
|
|
145
|
+
|
|
146
|
+
me = (@api || Channels::Telegram::Api.new(token)).get_me
|
|
147
|
+
ok("Bot", "@#{me['username']}")
|
|
148
|
+
rescue Channels::Telegram::Api::Unavailable
|
|
149
|
+
warn("Bot", "network unavailable — couldn't check")
|
|
150
|
+
rescue Channels::Telegram::Api::Error => e
|
|
151
|
+
fail("Bot", e.message, "check the token: agents_control setup")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A successful connection alone means nothing: something else could
|
|
155
|
+
# be sitting on the port, and in some environments a local connect
|
|
156
|
+
# always succeeds. So a request is sent and the response is
|
|
157
|
+
# inspected — our server refuses a request with no secret, and
|
|
158
|
+
# that's its signature.
|
|
159
|
+
def daemon_check
|
|
160
|
+
port = @config.get("hooks.port", Daemon::DEFAULT_PORT)
|
|
161
|
+
|
|
162
|
+
case daemon_response(port)
|
|
163
|
+
when :ours then ok("Daemon", "running on port #{port}")
|
|
164
|
+
when :stranger then fail("Daemon", "port #{port} is taken by something else",
|
|
165
|
+
"change hooks.port in settings")
|
|
166
|
+
else warn("Daemon", "not running", "agents_control daemon")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def daemon_response(port)
|
|
171
|
+
socket = Socket.tcp("127.0.0.1", port, connect_timeout: 1)
|
|
172
|
+
socket.print("POST /ping HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}")
|
|
173
|
+
status = socket.readline
|
|
174
|
+
|
|
175
|
+
status.include?("403") ? :ours : :stranger
|
|
176
|
+
rescue StandardError
|
|
177
|
+
:down
|
|
178
|
+
ensure
|
|
179
|
+
socket&.close
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def hooks_check
|
|
183
|
+
agent = Agents::ClaudeCode.new
|
|
184
|
+
return ok("Hooks", "connected") if agent.installed?
|
|
185
|
+
|
|
186
|
+
# Not an error: the daemon connects them itself on start and
|
|
187
|
+
# removes them on stop, or the agent would complain about an
|
|
188
|
+
# unreachable address.
|
|
189
|
+
warn("Hooks", "not connected — expected while the daemon is off")
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
fail("Hooks", e.message)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def anchors_check
|
|
195
|
+
return warn("Rate-limit anchors", "off", "turn on in /settings") unless @config.get("anchors.enabled", false)
|
|
196
|
+
|
|
197
|
+
scheduler = Anchors::Scheduler.new(config: @config, store: Store.new)
|
|
198
|
+
at = scheduler.next_run_at
|
|
199
|
+
|
|
200
|
+
ok("Rate-limit anchors", "model #{@config.get('anchors.model')}, next at #{at&.strftime('%d.%m %H:%M')}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A 7am anchor won't fire if the laptop is asleep: launchd runs the
|
|
204
|
+
# task after waking, and the precise timing is lost.
|
|
205
|
+
def wake_check
|
|
206
|
+
return nil unless macos?
|
|
207
|
+
return nil unless @config.get("anchors.enabled", false)
|
|
208
|
+
|
|
209
|
+
scheduled = @executor.run(Which.find("pmset"), "-g", "sched").stdout.to_s
|
|
210
|
+
|
|
211
|
+
return ok("Wake schedule", "configured") if scheduled.match?(/wake|poweron/i)
|
|
212
|
+
|
|
213
|
+
warn("Wake schedule", "the Mac could be asleep at anchor time",
|
|
214
|
+
"sudo pmset repeat wakeorpoweron MTWRF #{earliest_anchor}:00")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def earliest_anchor
|
|
218
|
+
Array(@config.get("anchors.schedule", [])).min.to_s.sub(/\A(\d):/, "0\\1:")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def macos? = RUBY_PLATFORM.include?("darwin")
|
|
222
|
+
|
|
223
|
+
def iterm_running?
|
|
224
|
+
ProcessProbe.new(executor: @executor).refresh.running?("iTerm2")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def port_open?(port)
|
|
228
|
+
Socket.tcp("127.0.0.1", port, connect_timeout: 1, &:close)
|
|
229
|
+
true
|
|
230
|
+
rescue StandardError
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# A normalized event from an agent.
|
|
5
|
+
#
|
|
6
|
+
# The core and the notification channel operate only on this type and
|
|
7
|
+
# know nothing about Claude Code or Codex. An agent adapter's job is to
|
|
8
|
+
# bring its own payload into this shape, and that's the extent of its
|
|
9
|
+
# knowledge of the outside world.
|
|
10
|
+
#
|
|
11
|
+
# The shape wasn't picked arbitrarily: Claude Code and Codex independently
|
|
12
|
+
# converged on nearly the same set of events and the same way of
|
|
13
|
+
# returning a decision, so this describes an existing commonality rather
|
|
14
|
+
# than a speculative abstraction.
|
|
15
|
+
class Event
|
|
16
|
+
KINDS = %i[
|
|
17
|
+
needs_permission
|
|
18
|
+
needs_input
|
|
19
|
+
finished
|
|
20
|
+
error
|
|
21
|
+
started
|
|
22
|
+
ended
|
|
23
|
+
progress
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :kind, :agent, :session_id, :cwd, :text, :options,
|
|
27
|
+
:tool_name, :tool_input, :transcript_path, :raw
|
|
28
|
+
|
|
29
|
+
def initialize(kind:, agent:, session_id:, cwd: nil, text: nil, options: [],
|
|
30
|
+
tool_name: nil, tool_input: nil, transcript_path: nil, raw: {})
|
|
31
|
+
raise ArgumentError, "unknown event kind: #{kind}" unless KINDS.include?(kind)
|
|
32
|
+
|
|
33
|
+
@kind = kind
|
|
34
|
+
@agent = agent
|
|
35
|
+
@session_id = session_id
|
|
36
|
+
@cwd = cwd
|
|
37
|
+
@text = text
|
|
38
|
+
@options = options
|
|
39
|
+
@tool_name = tool_name
|
|
40
|
+
@tool_input = tool_input
|
|
41
|
+
@transcript_path = transcript_path
|
|
42
|
+
@raw = raw
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Does a human need to weigh in. Everything else is background info.
|
|
46
|
+
def question? = %i[needs_permission needs_input].include?(kind)
|
|
47
|
+
|
|
48
|
+
# AskUserQuestion is structurally a permission request (it goes
|
|
49
|
+
# through the same PermissionRequest hook as any tool call), but
|
|
50
|
+
# answering it means picking one of its listed options — allow/deny
|
|
51
|
+
# doesn't apply, and neither does the usual reply-becomes-hook-response path.
|
|
52
|
+
def ask_user_question? = tool_name == "AskUserQuestion"
|
|
53
|
+
|
|
54
|
+
# Short name of where this is happening. The full path in a
|
|
55
|
+
# notification only gets in the way — what matters in a list is
|
|
56
|
+
# recognizing the project, not seeing /Users/...
|
|
57
|
+
def label
|
|
58
|
+
return File.basename(cwd) if cwd && !cwd.empty?
|
|
59
|
+
|
|
60
|
+
session_id.to_s[0, 8]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Description of what the agent is about to do.
|
|
64
|
+
def summary
|
|
65
|
+
case kind
|
|
66
|
+
when :needs_permission then permission_summary
|
|
67
|
+
when :needs_input then text.to_s
|
|
68
|
+
when :error then "error: #{text}"
|
|
69
|
+
else text.to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# AskUserQuestion isn't a "permission" — it's a question with options
|
|
76
|
+
# to pick from; the tool name won't tell a human anything, the
|
|
77
|
+
# question itself is what matters.
|
|
78
|
+
def permission_summary
|
|
79
|
+
return describe_questions if ask_user_question?
|
|
80
|
+
|
|
81
|
+
"#{tool_name}: #{describe_tool}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Every tool has its own main field; show that, not the whole JSON.
|
|
85
|
+
def describe_tool
|
|
86
|
+
return "" unless tool_input.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
value = tool_input["command"] || tool_input["file_path"] ||
|
|
89
|
+
tool_input["pattern"] || tool_input["url"]
|
|
90
|
+
|
|
91
|
+
value.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def describe_questions
|
|
95
|
+
questions = tool_input.is_a?(Hash) ? tool_input["questions"] : nil
|
|
96
|
+
return "" unless questions.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
questions.map { |question| describe_question(question) }.join("\n\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def describe_question(question)
|
|
102
|
+
lines = ["#{question['header']}: #{question['question']}"]
|
|
103
|
+
|
|
104
|
+
Array(question["options"]).each_with_index do |option, index|
|
|
105
|
+
lines << "#{index + 1}. #{option['label']} — #{option['description']}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
lines.join("\n")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module AgentsControl
|
|
6
|
+
# The single point through which the utility talks to the outside world.
|
|
7
|
+
#
|
|
8
|
+
# Exists for the "adapter isolation" rule: osascript can hang dead when
|
|
9
|
+
# iTerm2 shows a beachball, and without a hard timeout that takes the
|
|
10
|
+
# whole daemon down with it. No adapter calls Open3 directly.
|
|
11
|
+
#
|
|
12
|
+
# Tests swap this for FakeExecutor — no real terminal needed in CI.
|
|
13
|
+
class Executor
|
|
14
|
+
DEFAULT_TIMEOUT = 10
|
|
15
|
+
|
|
16
|
+
# status 124 — coreutils timeout(1) convention, 127 — "command not found".
|
|
17
|
+
TIMEOUT_STATUS = 124
|
|
18
|
+
NOT_FOUND_STATUS = 127
|
|
19
|
+
|
|
20
|
+
Result = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
|
|
21
|
+
def success? = status.zero?
|
|
22
|
+
def timeout? = status == TIMEOUT_STATUS
|
|
23
|
+
def not_found? = status == NOT_FOUND_STATUS
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(timeout: DEFAULT_TIMEOUT)
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Always returns a Result — never raises. A failing external command
|
|
31
|
+
# must not take the daemon down with it.
|
|
32
|
+
def run(*argv, stdin: nil, timeout: @timeout)
|
|
33
|
+
Open3.popen3(*argv) do |input, output, errors, wait_thread|
|
|
34
|
+
write_stdin(input, stdin)
|
|
35
|
+
|
|
36
|
+
# Read on separate threads: otherwise a process that fills the
|
|
37
|
+
# stderr buffer blocks waiting to be read, while we block waiting
|
|
38
|
+
# for it to exit. report_on_exception is off on purpose: on a
|
|
39
|
+
# timeout, the reader threads find popen3's pipe already closed
|
|
40
|
+
# and raise IOError. That's expected and handled below, but Ruby
|
|
41
|
+
# prints that trace to stderr by default, where it looks like a
|
|
42
|
+
# real crash in the utility's console.
|
|
43
|
+
out_reader = Thread.new { output.read }
|
|
44
|
+
err_reader = Thread.new { errors.read }
|
|
45
|
+
[out_reader, err_reader].each { |thread| thread.report_on_exception = false }
|
|
46
|
+
|
|
47
|
+
return kill(wait_thread, out_reader, err_reader, timeout) unless wait_thread.join(timeout)
|
|
48
|
+
|
|
49
|
+
Result.new(
|
|
50
|
+
stdout: out_reader.value.to_s,
|
|
51
|
+
stderr: err_reader.value.to_s,
|
|
52
|
+
status: wait_thread.value.exitstatus || -1
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
56
|
+
Result.new(stdout: "", stderr: e.message, status: NOT_FOUND_STATUS)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def write_stdin(input, data)
|
|
62
|
+
input.write(data) if data
|
|
63
|
+
input.close
|
|
64
|
+
rescue Errno::EPIPE
|
|
65
|
+
# The command exited without reading stdin — that's its right.
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def kill(wait_thread, *readers, timeout)
|
|
69
|
+
begin
|
|
70
|
+
Process.kill("KILL", wait_thread.pid)
|
|
71
|
+
wait_thread.join
|
|
72
|
+
rescue Errno::ESRCH
|
|
73
|
+
# The process died on its own between join and kill.
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Readers are stopped explicitly: otherwise they stay hanging on
|
|
77
|
+
# already-closed pipes and pile up on every timeout.
|
|
78
|
+
readers.each(&:kill)
|
|
79
|
+
|
|
80
|
+
Result.new(stdout: "", stderr: "command didn't respond within #{timeout}s", status: TIMEOUT_STATUS)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|