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,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# The interactive console.
|
|
5
|
+
#
|
|
6
|
+
# The tool is meant to live in a tab, not run once and exit: that's
|
|
7
|
+
# its working state — it listens to Telegram and receives agent events
|
|
8
|
+
# while it's open. Separate subcommands would be misleading here: after
|
|
9
|
+
# `setup` it would look like everything's running, while in fact
|
|
10
|
+
# nothing is polling Telegram, and messages pile up unread on its side.
|
|
11
|
+
#
|
|
12
|
+
# So running with no arguments opens the console itself, and the
|
|
13
|
+
# commands inside it are the same as the bot's — with a slash.
|
|
14
|
+
class Console
|
|
15
|
+
COMMANDS = {
|
|
16
|
+
"/help" => "list of commands",
|
|
17
|
+
"/sessions" => "sessions with a live agent",
|
|
18
|
+
"/tabs" => "all terminal tabs",
|
|
19
|
+
"/status" => "what's happening right now",
|
|
20
|
+
"/away" => "intercept agent questions (before stepping out)",
|
|
21
|
+
"/settings" => "settings; /settings NAME — toggle",
|
|
22
|
+
"/token" => "set the Telegram token",
|
|
23
|
+
"/doctor" => "check that everything is in place",
|
|
24
|
+
"/hooks" => "hook status; /hooks install|uninstall",
|
|
25
|
+
"/quit" => "quit"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Values worth suggesting as a second word.
|
|
29
|
+
ARGUMENTS = {
|
|
30
|
+
"/away" => %w[on off],
|
|
31
|
+
"/hooks" => %w[install uninstall]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(config: nil, store: nil, secrets: nil, output: $stdout)
|
|
35
|
+
@config = config || Config.load
|
|
36
|
+
@store = store || Store.new
|
|
37
|
+
@secrets = secrets || Secrets.new
|
|
38
|
+
@out = output
|
|
39
|
+
@out.sync = true if @out.respond_to?(:sync=)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
banner
|
|
44
|
+
start_daemon
|
|
45
|
+
loop_commands
|
|
46
|
+
ensure
|
|
47
|
+
say("")
|
|
48
|
+
say("Stopping…")
|
|
49
|
+
@daemon&.stop
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def say(text = "") = @out.puts(text)
|
|
55
|
+
|
|
56
|
+
def banner
|
|
57
|
+
say("agents_control #{VERSION}")
|
|
58
|
+
say("Type / for the command list, arrows to pick, Enter to fill in.\nQuit with /quit or Ctrl-D.")
|
|
59
|
+
say("")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The line is passed as an argument rather than read from the editor
|
|
63
|
+
# internally: this lets completions be tested without a live terminal.
|
|
64
|
+
def complete(word, line: word)
|
|
65
|
+
command, rest = line.split(/\s+/, 2)
|
|
66
|
+
|
|
67
|
+
rest.nil? ? complete_command(word) : complete_argument(command, word)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The slash is filled in automatically: typing it every time isn't
|
|
71
|
+
# required, and the list is shown in full either way.
|
|
72
|
+
def complete_command(word)
|
|
73
|
+
needle = word.start_with?("/") ? word : "/#{word}"
|
|
74
|
+
|
|
75
|
+
COMMANDS.keys.select { |name| name.start_with?(needle) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def complete_argument(command, word)
|
|
79
|
+
values = case command
|
|
80
|
+
when "/settings" then setting_names
|
|
81
|
+
else ARGUMENTS.fetch(command, [])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
values.select { |value| value.start_with?(word) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A short setting name for the completion hint.
|
|
88
|
+
#
|
|
89
|
+
# Usually the key's last word, but for `anchors.enabled` that
|
|
90
|
+
# degenerates into a meaningless "enabled" — unclear what's actually
|
|
91
|
+
# on. For cases like that, the section is used instead: "anchors" speaks for itself.
|
|
92
|
+
GENERIC = %w[enabled].freeze
|
|
93
|
+
|
|
94
|
+
def setting_names
|
|
95
|
+
(Channels::Telegram::SettingsMenu::TOGGLES +
|
|
96
|
+
Channels::Telegram::SettingsMenu::CHOICES).map { |item| short_name(item[:key]) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def short_name(key)
|
|
100
|
+
section, *rest = key.split(".")
|
|
101
|
+
|
|
102
|
+
GENERIC.include?(rest.last) ? section : rest.last
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The daemon comes up immediately: a console without it is just a
|
|
106
|
+
# window, and the point is to already be connected the moment the tab opens.
|
|
107
|
+
def start_daemon
|
|
108
|
+
@daemon = Daemon.new(config: @config, store: @store, secrets: @secrets, logger: @out)
|
|
109
|
+
|
|
110
|
+
return say("") if @daemon.start
|
|
111
|
+
|
|
112
|
+
say("")
|
|
113
|
+
say("The daemon isn't running — the commands below still work.")
|
|
114
|
+
say("")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def loop_commands
|
|
118
|
+
while (line = prompt)
|
|
119
|
+
line = line.strip
|
|
120
|
+
next if line.empty?
|
|
121
|
+
|
|
122
|
+
break if %w[/quit /exit /q].include?(line)
|
|
123
|
+
|
|
124
|
+
execute(line)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Without a terminal — a plain gets: control sequences over a pipe
|
|
129
|
+
# would turn into garbage, and the console stays usable from scripts.
|
|
130
|
+
def prompt
|
|
131
|
+
return plain_prompt unless $stdin.tty?
|
|
132
|
+
|
|
133
|
+
editor.read(prompt_text)
|
|
134
|
+
rescue Interrupt
|
|
135
|
+
# Ctrl-C mid-line clears the line rather than quitting the program:
|
|
136
|
+
# quitting is /quit, so an accidental keypress doesn't drop the connection.
|
|
137
|
+
say("^C")
|
|
138
|
+
""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def editor
|
|
142
|
+
@editor ||= Prompt.new(
|
|
143
|
+
output: @out,
|
|
144
|
+
completer: ->(word) { complete(word, line: word) },
|
|
145
|
+
describer: ->(name) { COMMANDS[name] }
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def plain_prompt
|
|
150
|
+
@out.print(prompt_text)
|
|
151
|
+
$stdin.gets
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The prompt itself shows the state: after stepping away it's easy
|
|
155
|
+
# to forget interception is off, and end up with no notifications exactly when they're needed.
|
|
156
|
+
def prompt_text
|
|
157
|
+
@config.get("answers.away", false) ? "🚶 > " : "> "
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def execute(line)
|
|
161
|
+
command, argument = line.split(/\s+/, 2)
|
|
162
|
+
command = "/#{command}" unless command.start_with?("/")
|
|
163
|
+
|
|
164
|
+
case command
|
|
165
|
+
# A bare slash means "show me what's here" — typing it and hitting
|
|
166
|
+
# enter is more natural than remembering the word help.
|
|
167
|
+
when "/", "//" then help
|
|
168
|
+
when "/help" then help
|
|
169
|
+
when "/sessions" then list(registry.refresh.agents, "Agent sessions")
|
|
170
|
+
when "/tabs" then list(registry.refresh.sessions, "All tabs")
|
|
171
|
+
when "/status" then status
|
|
172
|
+
when "/away" then away(argument)
|
|
173
|
+
when "/settings" then settings(argument)
|
|
174
|
+
when "/token" then token
|
|
175
|
+
when "/doctor" then doctor
|
|
176
|
+
when "/hooks" then hooks(argument)
|
|
177
|
+
else say("I don't know the command #{command}. Type /help.")
|
|
178
|
+
end
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
say("Error: #{e.class}: #{e.message}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def help
|
|
184
|
+
width = COMMANDS.keys.map(&:length).max
|
|
185
|
+
COMMANDS.each { |name, text| say(" #{name.ljust(width)} #{text}") }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def registry = @registry ||= Registry.new
|
|
189
|
+
|
|
190
|
+
def list(sessions, title)
|
|
191
|
+
return say("#{title}: empty.") if sessions.empty?
|
|
192
|
+
|
|
193
|
+
say(title)
|
|
194
|
+
sessions.each_with_index do |session, index|
|
|
195
|
+
say(format("%3d. %s %s", index + 1, marker(session), describe(session)))
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def marker(session)
|
|
200
|
+
return "⏳" if session.processing?
|
|
201
|
+
return "🖥" if session.terminalless?
|
|
202
|
+
return "▸" if session.at_shell_prompt?
|
|
203
|
+
|
|
204
|
+
"·"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def describe(session)
|
|
208
|
+
agent = session.agent ? session.agent.to_s : (session.foreground_command || "—")
|
|
209
|
+
place = session.terminalless? ? "vscode" : session.tty.to_s.sub(%r{\A/dev/}, "")
|
|
210
|
+
|
|
211
|
+
"#{agent} · #{session.label} · #{place}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def status
|
|
215
|
+
sessions = registry.refresh.sessions
|
|
216
|
+
|
|
217
|
+
say("Tabs: #{sessions.size}, with an agent: #{sessions.count(&:agent?)}")
|
|
218
|
+
say("Backends: #{registry.available_backends.map(&:name).join(', ')}")
|
|
219
|
+
say("Mode: #{@daemon&.away_label || 'daemon not running'}")
|
|
220
|
+
say("Waiting for a reply: #{@daemon ? @daemon.pending.size : 0}")
|
|
221
|
+
say("Chats: #{Array(@config.get('telegram.allowed_chat_ids', [])).join(', ')}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def away(argument)
|
|
225
|
+
value = case argument.to_s.strip.downcase
|
|
226
|
+
when "on", "yes" then true
|
|
227
|
+
when "off", "no" then false
|
|
228
|
+
else !@config.get("answers.away", false)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@config.set("answers.away", value).save
|
|
232
|
+
say(value ? "🚶 Away. Agent questions go to Telegram and wait for a reply." :
|
|
233
|
+
"🪑 Present. Questions stay in the terminal.")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def settings(argument)
|
|
237
|
+
return open_settings if argument.to_s.strip.empty?
|
|
238
|
+
|
|
239
|
+
key = resolve_setting(argument.strip)
|
|
240
|
+
return say("I don't know the setting #{argument}. Type /settings with no argument.") unless key
|
|
241
|
+
|
|
242
|
+
say(settings_menu.apply({ "key" => key }))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def settings_menu
|
|
246
|
+
Channels::Telegram::SettingsMenu.new(store: @store, config: @config)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Showing human-readable names but requiring the internal name back
|
|
250
|
+
# is a trap: you see "Rate-limit anchors," type it, and get
|
|
251
|
+
# rejected. So the settings list is navigated with arrows, same as the commands.
|
|
252
|
+
def open_settings
|
|
253
|
+
return say(settings_menu.text) unless $stdin.tty?
|
|
254
|
+
|
|
255
|
+
items = Channels::Telegram::SettingsMenu::TOGGLES +
|
|
256
|
+
Channels::Telegram::SettingsMenu::CHOICES
|
|
257
|
+
|
|
258
|
+
Menu.new(output: @out).run(
|
|
259
|
+
title: "⚙️ Settings",
|
|
260
|
+
rows: -> { settings_menu.rows }
|
|
261
|
+
) { |index| settings_menu.apply({ "key" => items[index][:key] }) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Accepts the short name, the full key, and the exact label a human
|
|
265
|
+
# saw on screen: rejecting the very string that was just shown is
|
|
266
|
+
# the worst thing a completion hint could do.
|
|
267
|
+
def resolve_setting(name)
|
|
268
|
+
needle = name.to_s.strip.downcase
|
|
269
|
+
items = Channels::Telegram::SettingsMenu::TOGGLES +
|
|
270
|
+
Channels::Telegram::SettingsMenu::CHOICES
|
|
271
|
+
|
|
272
|
+
item = items.find do |entry|
|
|
273
|
+
key = entry[:key]
|
|
274
|
+
[key, key.split(".").last, short_name(key), entry[:label]].compact
|
|
275
|
+
.map { |value| value.downcase } .include?(needle)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
item && item[:key]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def token
|
|
282
|
+
say("The token isn't displayed and isn't kept in history.")
|
|
283
|
+
value = ask_secret("Token from @BotFather: ")
|
|
284
|
+
return say("Empty — nothing changed.") if value.empty?
|
|
285
|
+
|
|
286
|
+
@secrets.set(:telegram_token, value)
|
|
287
|
+
say("Saved to: #{@secrets.target.name}. Restart the console to apply it.")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def ask_secret(label)
|
|
291
|
+
@out.print(label)
|
|
292
|
+
$stdin.noecho(&:gets).to_s.strip
|
|
293
|
+
rescue SystemCallError, IOError
|
|
294
|
+
""
|
|
295
|
+
ensure
|
|
296
|
+
say("")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def doctor
|
|
300
|
+
Doctor.new(config: @config, secrets: @secrets).run.each do |check|
|
|
301
|
+
say("#{check.icon} #{check.name.ljust(22)} #{check.detail}")
|
|
302
|
+
say(" └ #{check.fix}") if check.fix
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def hooks(argument)
|
|
307
|
+
agent = Agents::ClaudeCode.new
|
|
308
|
+
|
|
309
|
+
case argument.to_s.strip
|
|
310
|
+
when "install" then say(agent.install!(hook_url, secret: hook_secret) ? "Connected." : "Couldn't connect.")
|
|
311
|
+
when "uninstall" then say(agent.uninstall! ? "Disconnected." : "Couldn't disconnect.")
|
|
312
|
+
else say("Hooks are #{agent.installed? ? 'connected' : 'not connected'}.")
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def hook_url = "http://127.0.0.1:#{@config.get('hooks.port', Daemon::DEFAULT_PORT)}"
|
|
317
|
+
|
|
318
|
+
def hook_secret = @secrets.get(:hook_secret)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
# Brings everything together: receiving hooks from agents and talking to Telegram.
|
|
8
|
+
#
|
|
9
|
+
# One process, because one Telegram token allows exactly one update
|
|
10
|
+
# reader, and a thread waiting on a question's answer has to get that
|
|
11
|
+
# answer from the same process that asked it.
|
|
12
|
+
class Daemon
|
|
13
|
+
# The port is fixed, not picked freely on every start: the address
|
|
14
|
+
# is written into an agent's own settings and has to survive a
|
|
15
|
+
# daemon restart, or every restart would leave hooks firing into nothing.
|
|
16
|
+
DEFAULT_PORT = 47_653
|
|
17
|
+
|
|
18
|
+
def initialize(config: nil, store: nil, secrets: nil, logger: $stdout)
|
|
19
|
+
@config = config || Config.load
|
|
20
|
+
@store = store || Store.new
|
|
21
|
+
@secrets = secrets || Secrets.new
|
|
22
|
+
@logger = logger
|
|
23
|
+
|
|
24
|
+
# Without this, output buffers until the process exits. Under
|
|
25
|
+
# launchd that means an empty log for a daemon that's actually
|
|
26
|
+
# running — blind exactly when the log is what you'd reach for.
|
|
27
|
+
@logger.sync = true if @logger.respond_to?(:sync=)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :bot
|
|
31
|
+
|
|
32
|
+
# Exposed: the console needs to show how many questions are
|
|
33
|
+
# currently waiting for an answer.
|
|
34
|
+
def pending = @pending ||= Pending.new
|
|
35
|
+
|
|
36
|
+
# Bring everything up and return control immediately.
|
|
37
|
+
#
|
|
38
|
+
# Split from waiting for the sake of the interactive console: it
|
|
39
|
+
# holds its own input loop while the daemon runs alongside. The
|
|
40
|
+
# `daemon` command is this same start plus waiting.
|
|
41
|
+
def start
|
|
42
|
+
return false unless ready?
|
|
43
|
+
|
|
44
|
+
start_hooks
|
|
45
|
+
start_anchors
|
|
46
|
+
start_screen_watcher
|
|
47
|
+
start_rate_limit_watcher
|
|
48
|
+
install_agents
|
|
49
|
+
verify_hooks
|
|
50
|
+
publish_commands
|
|
51
|
+
start_bot
|
|
52
|
+
|
|
53
|
+
log("hooks listening on #{server.url}, agents connected: #{installed_agents.size}")
|
|
54
|
+
log("mode: #{away_label}")
|
|
55
|
+
true
|
|
56
|
+
rescue Hooks::Server::PortBusy => e
|
|
57
|
+
fail_with(e.message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def wait = @bot&.wait
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
@bot&.stop
|
|
64
|
+
server&.stop
|
|
65
|
+
@scheduler&.stop
|
|
66
|
+
@screen_watcher&.stop
|
|
67
|
+
@rate_limit_watcher&.stop
|
|
68
|
+
remove_agents
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run
|
|
72
|
+
return false unless start
|
|
73
|
+
|
|
74
|
+
Signal.trap("INT") { @bot&.stop }
|
|
75
|
+
Signal.trap("TERM") { @bot&.stop }
|
|
76
|
+
wait
|
|
77
|
+
ensure
|
|
78
|
+
stop
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def away_label
|
|
82
|
+
@config.get("answers.away", false) ? "away — questions go to Telegram" : "present — notifications only"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ready?
|
|
86
|
+
return fail_with("Token not found. Enter /token or run agents_control setup") unless
|
|
87
|
+
@secrets.get(:telegram_token)
|
|
88
|
+
return fail_with("The allowed-chats list is empty. Run: agents_control setup") if chats.empty?
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def chats = Array(@config.get("telegram.allowed_chat_ids", []))
|
|
96
|
+
|
|
97
|
+
def port = @config.get("hooks.port", DEFAULT_PORT)
|
|
98
|
+
|
|
99
|
+
def agents = @agents ||= [Agents::ClaudeCode.new]
|
|
100
|
+
|
|
101
|
+
def installed_agents = agents.select(&:installed?)
|
|
102
|
+
|
|
103
|
+
def server
|
|
104
|
+
@server ||= Hooks::Server.new(port: port, secret: hook_secret, logger: @logger)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The secret doesn't protect against another user on the same
|
|
108
|
+
# machine — they can already read the settings file — it protects
|
|
109
|
+
# against outside requests to the local port, including whatever a
|
|
110
|
+
# page open in a browser might send.
|
|
111
|
+
def hook_secret
|
|
112
|
+
@hook_secret ||= @secrets.get(:hook_secret) || begin
|
|
113
|
+
generated = SecureRandom.hex(16)
|
|
114
|
+
@secrets.set(:hook_secret, generated)
|
|
115
|
+
generated
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def start_hooks
|
|
120
|
+
dispatcher = Dispatcher.new(
|
|
121
|
+
agents: agents,
|
|
122
|
+
channel: channel,
|
|
123
|
+
config: @config,
|
|
124
|
+
pending: pending,
|
|
125
|
+
logger: @logger
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
server.start do |_path, payload|
|
|
129
|
+
# Any event means a human was just working with the agent. This
|
|
130
|
+
# is how the anchor scheduler knows whether a rate-limit window
|
|
131
|
+
# is open — no separate polling needed.
|
|
132
|
+
@store.put(Time.now.to_i, ttl: 86_400, key: Anchors::Scheduler::ACTIVITY_KEY)
|
|
133
|
+
|
|
134
|
+
dispatcher.handle(payload)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def start_anchors
|
|
139
|
+
@scheduler = Anchors::Scheduler.new(
|
|
140
|
+
config: @config, store: @store, logger: @logger
|
|
141
|
+
).start
|
|
142
|
+
|
|
143
|
+
return unless @config.get("anchors.enabled", false)
|
|
144
|
+
|
|
145
|
+
log("anchors enabled, next one at #{@scheduler.next_run_at&.strftime('%d.%m %H:%M') || 'never'}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def channel
|
|
149
|
+
@channel ||= Channels::Telegram::Channel.new(api: api, store: @store, config: @config,
|
|
150
|
+
registry: Registry.new)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def start_screen_watcher
|
|
154
|
+
@screen_watcher = ScreenWatcher.new(
|
|
155
|
+
registry: Registry.new, config: @config, store: @store, api: api, logger: @logger
|
|
156
|
+
).start
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Goes through the full Registry, like ScreenWatcher, but polls
|
|
160
|
+
# noticeably less often (once a minute, not every 20 seconds) — a
|
|
161
|
+
# limit doesn't hit every few seconds, and an AppleScript scan
|
|
162
|
+
# (~0.9s) at that frequency costs less this way.
|
|
163
|
+
def start_rate_limit_watcher
|
|
164
|
+
@rate_limit_watcher = RateLimitWatcher.new(
|
|
165
|
+
registry: Registry.new, config: @config, store: @store, api: api,
|
|
166
|
+
interval: @config.get("terminal.rate_limit_poll_interval", 60), logger: @logger
|
|
167
|
+
).start
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def api = @api ||= Channels::Telegram::Api.new(@secrets.get(:telegram_token))
|
|
171
|
+
|
|
172
|
+
def install_agents
|
|
173
|
+
@installed = true
|
|
174
|
+
|
|
175
|
+
agents.each do |agent|
|
|
176
|
+
# Headroom over the reply timeout: the hook has to outlive our
|
|
177
|
+
# own timeout, not get cut off a second before it.
|
|
178
|
+
agent.install!(server.url, secret: hook_secret,
|
|
179
|
+
timeout: @config.get("answers.reply_timeout", 600) + 60)
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
log("couldn't connect #{agent.key}: #{e.message}")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Self-check: does an event from the agent actually reach us. The
|
|
186
|
+
# entry in settings.json and the live server can drift apart — for
|
|
187
|
+
# instance, if another agents_control is already running nearby with
|
|
188
|
+
# a different secret.
|
|
189
|
+
def verify_hooks
|
|
190
|
+
settings = JSON.parse(File.read(Agents::ClaudeCode.settings_path))
|
|
191
|
+
hook = settings.dig("hooks", "Stop", 0, "hooks", 0)
|
|
192
|
+
return log("couldn't find our own hooks in settings — events won't arrive") unless hook
|
|
193
|
+
|
|
194
|
+
code = probe_hook(hook)
|
|
195
|
+
return if code == 200
|
|
196
|
+
|
|
197
|
+
log("WARNING: the hook returned #{code}, agent events won't reach me.")
|
|
198
|
+
log("This usually means another agents_control is running nearby — stop it.")
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
log("couldn't verify hooks: #{e.message}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def probe_hook(hook)
|
|
204
|
+
uri = URI(hook["url"])
|
|
205
|
+
request = Net::HTTP::Post.new(uri)
|
|
206
|
+
request["Content-Type"] = "application/json"
|
|
207
|
+
hook.fetch("headers", {}).each { |key, value| request[key] = value }
|
|
208
|
+
request.body = JSON.generate({ "hook_event_name" => "Ping" })
|
|
209
|
+
|
|
210
|
+
Net::HTTP.start(uri.host, uri.port, read_timeout: 5) { |http| http.request(request) }.code.to_i
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def publish_commands
|
|
214
|
+
api.set_my_commands(Channels::Telegram::Router::COMMANDS)
|
|
215
|
+
rescue Channels::Telegram::Api::Error => e
|
|
216
|
+
log("couldn't update the command menu: #{e.message}")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Hooks are removed on stop: otherwise the agent prints ECONNREFUSED
|
|
220
|
+
# for every hook it has nowhere to reach. If the daemon crashes they
|
|
221
|
+
# stay dangling; fixed with the hooks uninstall command.
|
|
222
|
+
def remove_agents
|
|
223
|
+
return unless @installed
|
|
224
|
+
|
|
225
|
+
agents.each do |agent|
|
|
226
|
+
agent.uninstall!
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
log("couldn't disconnect #{agent.key}: #{e.message}")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
log("hooks disconnected")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def start_bot
|
|
235
|
+
router = Channels::Telegram::Router.new(
|
|
236
|
+
api: api, registry: Registry.new, store: @store,
|
|
237
|
+
config: @config, pending: pending
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@bot = Channels::Telegram::Bot.new(api: api, router: router, store: @store,
|
|
241
|
+
config: @config, logger: @logger).start
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def fail_with(message)
|
|
245
|
+
log(message)
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def log(message) = @logger.puts("[#{Time.now.strftime('%H:%M:%S')}] #{message}")
|
|
250
|
+
end
|
|
251
|
+
end
|