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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/README.md +282 -0
  4. data/exe/agents_control +6 -0
  5. data/lib/agents_control/agents/base.rb +52 -0
  6. data/lib/agents_control/agents/claude_code.rb +243 -0
  7. data/lib/agents_control/anchors/scheduler.rb +161 -0
  8. data/lib/agents_control/channels/base.rb +27 -0
  9. data/lib/agents_control/channels/telegram/api.rb +179 -0
  10. data/lib/agents_control/channels/telegram/bot.rb +146 -0
  11. data/lib/agents_control/channels/telegram/channel.rb +251 -0
  12. data/lib/agents_control/channels/telegram/chunker.rb +64 -0
  13. data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
  14. data/lib/agents_control/channels/telegram/markdown.rb +59 -0
  15. data/lib/agents_control/channels/telegram/router.rb +482 -0
  16. data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
  17. data/lib/agents_control/cli.rb +347 -0
  18. data/lib/agents_control/config.rb +178 -0
  19. data/lib/agents_control/console.rb +320 -0
  20. data/lib/agents_control/daemon.rb +251 -0
  21. data/lib/agents_control/dispatcher.rb +163 -0
  22. data/lib/agents_control/doctor.rb +234 -0
  23. data/lib/agents_control/event.rb +111 -0
  24. data/lib/agents_control/executor.rb +83 -0
  25. data/lib/agents_control/hooks/server.rb +197 -0
  26. data/lib/agents_control/keyboard.rb +90 -0
  27. data/lib/agents_control/menu.rb +100 -0
  28. data/lib/agents_control/pending.rb +70 -0
  29. data/lib/agents_control/process_probe.rb +136 -0
  30. data/lib/agents_control/prompt.rb +227 -0
  31. data/lib/agents_control/rate_limit_watcher.rb +202 -0
  32. data/lib/agents_control/registry.rb +115 -0
  33. data/lib/agents_control/reply.rb +41 -0
  34. data/lib/agents_control/screen_watcher.rb +158 -0
  35. data/lib/agents_control/secrets.rb +256 -0
  36. data/lib/agents_control/service.rb +165 -0
  37. data/lib/agents_control/session.rb +66 -0
  38. data/lib/agents_control/store.rb +132 -0
  39. data/lib/agents_control/terminals/base.rb +76 -0
  40. data/lib/agents_control/terminals/iterm2.rb +167 -0
  41. data/lib/agents_control/terminals/null.rb +27 -0
  42. data/lib/agents_control/terminals/tmux.rb +106 -0
  43. data/lib/agents_control/transcript.rb +123 -0
  44. data/lib/agents_control/version.rb +5 -0
  45. data/lib/agents_control/which.rb +59 -0
  46. data/lib/agents_control.rb +52 -0
  47. 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