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,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "open3"
5
+
6
+ module AgentsControl
7
+ class CLI < Thor
8
+ def self.exit_on_failure? = true
9
+
10
+ # With no arguments the console opens: the tool's working state is
11
+ # to live in a tab, not run once and exit.
12
+ default_task :console
13
+
14
+ desc "console", "Interactive console (default)"
15
+ long_desc <<~TEXT
16
+ Brings up the daemon and stays in the tab. Commands inside start
17
+ with a slash, same as the bot's: /sessions, /tabs, /away, /settings, /doctor.
18
+ TEXT
19
+ def console = Console.new(config: config, store: store, secrets: secrets).run
20
+
21
+ desc "sessions", "Show agent sessions"
22
+ long_desc <<~TEXT
23
+ By default prints only sessions with a confirmed live agent.
24
+ Confirmation comes from the process tree, not the tab title: the
25
+ title stays up after the agent exits and lies.
26
+
27
+ With --all, prints every terminal tab.
28
+ TEXT
29
+ option :all, type: :boolean, default: false, aliases: "-a",
30
+ desc: "All tabs, not just agents"
31
+ def sessions
32
+ registry = Registry.new
33
+ list = options[:all] ? registry.sessions : registry.agents
34
+
35
+ if list.empty?
36
+ say(options[:all] ? "No tabs found." : "No live agent sessions.", :yellow)
37
+ say("Available backends: #{backend_names(registry)}", :white)
38
+ return
39
+ end
40
+
41
+ print_table_of(list)
42
+ say("")
43
+ say("Total: #{list.size} · backends: #{backend_names(registry)}", :white)
44
+ end
45
+
46
+ desc "setup", "Set up the Telegram bot"
47
+ long_desc <<~TEXT
48
+ Asks for a token from @BotFather, verifies it, and waits for you
49
+ to send the bot /start — to learn your chat_id and add it to the
50
+ allowed list. While that list is empty, the bot answers nobody.
51
+
52
+ The token is entered without echo and saved to the Keychain or
53
+ libsecret. It's deliberately never accepted as a command-line
54
+ argument: it would leak into `ps` and shell history.
55
+ TEXT
56
+ def setup
57
+ token = ask_token
58
+ return if token.nil? || !valid_shape?(token)
59
+
60
+ api = Api.new(token)
61
+ me = verify(api)
62
+ return unless me
63
+
64
+ secrets.set(:telegram_token, token)
65
+ say("Token saved: #{secrets.target.name}", :green)
66
+ publish_commands(api)
67
+
68
+ capture_chat_id(api, me)
69
+ end
70
+
71
+ desc "daemon", "Run the daemon: agent hooks plus Telegram"
72
+ long_desc <<~TEXT
73
+ Listens to Telegram and receives events from agents. Stops with Ctrl-C.
74
+
75
+ While "present" mode is on, agent questions are only mirrored to
76
+ Telegram and stay in the terminal. The /away command in the bot
77
+ switches to interception: then a question waits for a reply from your phone.
78
+ TEXT
79
+ def daemon = Daemon.new(config: config, store: store, secrets: secrets).run
80
+
81
+ desc "hooks SUBCOMMAND", "Connect or disconnect agent hooks (install/uninstall/status)"
82
+ def hooks(subcommand = "status")
83
+ case subcommand
84
+ when "install" then hooks_install
85
+ when "uninstall" then hooks_uninstall
86
+ when "status" then hooks_status
87
+ else say("I don't know the subcommand #{subcommand}. Try install, uninstall, status.", :red)
88
+ end
89
+ end
90
+
91
+ desc "stop", "Stop a running daemon"
92
+ long_desc <<~TEXT
93
+ Finds the process holding the hooks port and asks it to exit.
94
+ Useful when an instance was left running somewhere and a new one won't start.
95
+ TEXT
96
+ def stop
97
+ port = config.get("hooks.port", Daemon::DEFAULT_PORT).to_i
98
+ # argv array, not a shell string: no reason to trust a shell to
99
+ # parse a port number pulled from config.
100
+ out, = Open3.capture3("lsof", "-nP", "-iTCP:#{port}", "-sTCP:LISTEN", "-t")
101
+ pids = out.split
102
+ return say("Nothing is running.", :yellow) if pids.empty?
103
+
104
+ pids.each do |pid|
105
+ Process.kill("TERM", pid.to_i)
106
+ say("Stopped #{pid}", :green)
107
+ rescue Errno::ESRCH, Errno::EPERM => e
108
+ say("Couldn't stop #{pid}: #{e.message}", :red)
109
+ end
110
+ end
111
+
112
+ desc "doctor", "Check that everything is in place"
113
+ long_desc <<~TEXT
114
+ Checks the environment the daemon will actually get, not the one
115
+ you're sitting in right now: an interactive shell lies about
116
+ versions and paths.
117
+ TEXT
118
+ def doctor
119
+ checks = Doctor.new(config: config, secrets: secrets).run
120
+
121
+ checks.each do |check|
122
+ colour = { ok: :green, warn: :yellow, fail: :red }[check.status]
123
+ say("#{check.icon} #{check.name.ljust(22)} #{check.detail}", colour)
124
+ say(" └ #{check.fix}", :white) if check.fix
125
+ end
126
+
127
+ broken = checks.count(&:failed?)
128
+ say("")
129
+ say(broken.zero? ? "Everything checks out." : "Not okay: #{broken}",
130
+ broken.zero? ? :green : :red)
131
+ end
132
+
133
+ desc "service SUBCOMMAND", "Autostart the daemon (install/uninstall/status)"
134
+ def service(subcommand = "status")
135
+ unit = Service.for_platform
136
+
137
+ case subcommand
138
+ when "install"
139
+ say("Installed #{unit.install}", :green)
140
+ say("Ruby: #{unit.ruby}", :white)
141
+ say("Log: #{unit.log_path}", :white)
142
+ when "uninstall" then unit.uninstall && say("Autostart removed", :green)
143
+ else say(unit.installed? ? "Autostart configured: #{unit.path}" : "Autostart not configured")
144
+ end
145
+ end
146
+
147
+ desc "version", "Version"
148
+ def version = say(AgentsControl::VERSION)
149
+
150
+ private
151
+
152
+ Api = Channels::Telegram::Api
153
+ Router = Channels::Telegram::Router
154
+ Bot = Channels::Telegram::Bot
155
+
156
+ def config = @config ||= Config.load
157
+
158
+ def store = @store ||= Store.new
159
+
160
+ def secrets = @secrets ||= Secrets.new
161
+
162
+ def agents = [Agents::ClaudeCode.new]
163
+
164
+ # Connecting hooks as a separate command usually isn't necessary —
165
+ # the daemon does it itself on start. The command exists for
166
+ # inspecting and fixing things by hand.
167
+ def hooks_install
168
+ url = "http://127.0.0.1:#{config.get('hooks.port', Daemon::DEFAULT_PORT)}"
169
+ secret = secrets.get(:hook_secret) || SecureRandom.hex(16)
170
+ secrets.set(:hook_secret, secret)
171
+
172
+ agents.each do |agent|
173
+ agent.install!(url, secret: secret)
174
+ say("#{agent.key}: connected on #{url}", :green)
175
+ rescue StandardError => e
176
+ say("#{agent.key}: #{e.message}", :red)
177
+ end
178
+ end
179
+
180
+ def hooks_uninstall
181
+ agents.each do |agent|
182
+ agent.uninstall!
183
+ say("#{agent.key}: disconnected", :green)
184
+ rescue StandardError => e
185
+ say("#{agent.key}: #{e.message}", :red)
186
+ end
187
+ end
188
+
189
+ def hooks_status
190
+ agents.each do |agent|
191
+ state = agent.installed? ? "connected" : "not connected"
192
+ say("#{agent.key}: hooks #{state}")
193
+ rescue StandardError => e
194
+ say("#{agent.key}: #{e.message}", :red)
195
+ end
196
+ end
197
+
198
+ # The token is read only from the terminal or stdin — deliberately
199
+ # never accepted as a command-line flag, or it would leak into `ps`
200
+ # and shell history.
201
+ def ask_token
202
+ token = $stdin.tty? ? ask_token_interactively : $stdin.gets.to_s.strip
203
+
204
+ return nil if token.empty? && warn_empty_token
205
+
206
+ token
207
+ end
208
+
209
+ def ask_token_interactively
210
+ $stderr.print("Token from @BotFather: ")
211
+ # Different devices fail differently: ENOTTY, ENODEV, ENXIO.
212
+ # Catching the whole class of system errors, not individual codes.
213
+ $stdin.noecho(&:gets).to_s.strip
214
+ rescue SystemCallError, IOError
215
+ ""
216
+ ensure
217
+ $stderr.puts
218
+ end
219
+
220
+ def warn_empty_token
221
+ say("No token received.", :yellow)
222
+ say("Enter it in the terminal, pipe it via stdin, or set " \
223
+ "AGENTS_CONTROL_TELEGRAM_TOKEN.", :white)
224
+ true
225
+ end
226
+
227
+ # A token from BotFather looks like `<digits>:<letters-digits-dashes>`.
228
+ # The shape is checked before touching the network: unprintable
229
+ # characters would land straight in the URL and fail the request
230
+ # with a cryptic address-parsing error.
231
+ TOKEN_SHAPE = /\A\d+:[A-Za-z0-9_-]{30,}\z/
232
+
233
+ def verify(api)
234
+ me = api.get_me
235
+ say("Bot: @#{me['username']}", :green)
236
+ me
237
+ rescue Channels::Telegram::Api::Error => e
238
+ say("Token was rejected: #{e.message}", :red)
239
+ nil
240
+ end
241
+
242
+ # Without this, the bot's command menu only appeared after the first
243
+ # daemon run (agents_control / agents_control daemon) — right after
244
+ # setup the bot would already answer, but the command list wouldn't
245
+ # be in Telegram's UI yet.
246
+ def publish_commands(api)
247
+ api.set_my_commands(Channels::Telegram::Router::COMMANDS)
248
+ rescue Channels::Telegram::Api::Error
249
+ nil
250
+ end
251
+
252
+ def valid_shape?(token)
253
+ return true if token.match?(TOKEN_SHAPE)
254
+
255
+ say("This doesn't look like a BotFather token.", :red)
256
+ say("Expecting something like 123456789:AA... — copy the whole string.", :white)
257
+ false
258
+ end
259
+
260
+ # chat_id is learned without asking: the user writes to the bot, and
261
+ # we read who from. This rules out a typo in a long number.
262
+ def capture_chat_id(api, me)
263
+ say("")
264
+ say("Now message the bot @#{me['username']} with /start", :yellow)
265
+ say("Waiting up to 120 seconds…")
266
+
267
+ chat = wait_for_message(api)
268
+ return say("Timed out. Run setup again.", :red) unless chat
269
+
270
+ allow(chat)
271
+ end
272
+
273
+ def wait_for_message(api, seconds: 120)
274
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
275
+ offset = nil
276
+
277
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
278
+ updates = api.get_updates(offset: offset, timeout: 20)
279
+ found = updates.find { |update| update.dig("message", "chat", "id") }
280
+
281
+ return found["message"]["chat"] if found
282
+
283
+ offset = updates.last["update_id"] + 1 unless updates.empty?
284
+ end
285
+
286
+ nil
287
+ rescue Channels::Telegram::Api::Error => e
288
+ say("Error while waiting: #{e.message}", :red)
289
+ nil
290
+ end
291
+
292
+ def allow(chat)
293
+ allowed = (config.get("telegram.allowed_chat_ids", []) + [chat["id"]]).uniq
294
+ config.set("telegram.allowed_chat_ids", allowed).save
295
+
296
+ name = [chat["first_name"], chat["username"] && "@#{chat['username']}"].compact.join(" ")
297
+ say("Done. Chat allowed: #{chat['id']} #{name}".strip, :green)
298
+ say("Run: agents_control bot")
299
+ end
300
+
301
+ def backend_names(registry)
302
+ names = registry.available_backends.map(&:name)
303
+ names.empty? ? "none" : names.join(", ")
304
+ end
305
+
306
+ def print_table_of(list)
307
+ rows = list.map { |session| row_for(session) }
308
+ widths = column_widths(rows)
309
+
310
+ rows.each do |cells|
311
+ say(cells.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip)
312
+ end
313
+ end
314
+
315
+ def row_for(session)
316
+ [
317
+ state_marker(session),
318
+ session.agent ? session.agent.to_s : (session.foreground_command || "—"),
319
+ session.label,
320
+ location(session),
321
+ session.id
322
+ ]
323
+ end
324
+
325
+ # State is encoded as an icon so the list reads at a glance, not
326
+ # column by column.
327
+ def state_marker(session)
328
+ return "⏳" if session.processing?
329
+ return "🖥" if session.terminalless?
330
+ return "▸" if session.at_shell_prompt?
331
+
332
+ "·"
333
+ end
334
+
335
+ # For a tabless session, printing a tty is meaningless — there isn't one.
336
+ def location(session)
337
+ return "vscode" if session.terminalless?
338
+
339
+ session.tty.to_s.sub(%r{\A/dev/}, "")
340
+ end
341
+
342
+ def column_widths(rows)
343
+ count = rows.map(&:size).max.to_i
344
+ (0...count).map { |i| rows.map { |cells| cells[i].to_s.length }.max }
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module AgentsControl
7
+ # YAML settings at the XDG path.
8
+ #
9
+ # Only non-secret data lives here. The bot token lives in the Keychain
10
+ # or libsecret and never lands in this file — the tool is meant to be
11
+ # published openly, and the config has to be safe to show anyone.
12
+ class Config
13
+ DEFAULTS = {
14
+ "telegram" => {
15
+ # While the list is empty, the bot answers nobody. Filled in by
16
+ # the setup wizard. Without this filter, anyone who found the bot
17
+ # could approve command execution on this machine.
18
+ "allowed_chat_ids" => [],
19
+ "poll_timeout" => 30
20
+ },
21
+ "answers" => {
22
+ # Answering "continue" on the user's behalf is safe.
23
+ "auto_continue" => true,
24
+ # Granting the agent a tool on the user's behalf is not.
25
+ # Deliberately two separate settings: merged into one, they'd
26
+ # produce an agent that approves itself everything while nobody
27
+ # is watching.
28
+ "auto_approve_permissions" => false,
29
+ # Whether to intercept agent questions at all. While a human is
30
+ # at the keyboard this only gets in the way: they'll answer in
31
+ # the terminal faster, and a busy hook keeps the dialog from
32
+ # ever reaching the screen. Turned on with the /away command.
33
+ "away" => false,
34
+ # Whether to also mirror questions to Telegram when interception
35
+ # is off.
36
+ "notify_when_present" => true,
37
+ # How long to wait for a Telegram reply before declining. Claude
38
+ # Code holds the hook open longer than the documented 600
39
+ # seconds, so this upper bound is ours, not its.
40
+ "reply_timeout" => 900,
41
+ # Automatically type a continuation once a session has hit a
42
+ # rate limit and the limit has reset. Exactly as safe as
43
+ # auto_continue — grants the agent no new permissions, just
44
+ # clears idle time nobody got around to clearing by hand.
45
+ "auto_resume_after_limit" => true,
46
+ "resume_message" => "Continue where you left off — the previous attempt was rate limited.",
47
+ # Never auto-approved, no matter what the settings above say.
48
+ "never_auto_approve" => [
49
+ "rm -rf",
50
+ "git push --force",
51
+ "curl | sh",
52
+ "curl | bash",
53
+ ".env"
54
+ ]
55
+ },
56
+ "anchors" => {
57
+ "enabled" => false,
58
+ "mode" => "headless",
59
+ # The five-hour window is shared across the account, but weekly
60
+ # limits are tracked per model family — so anchoring with a
61
+ # cheap model is the better deal.
62
+ "model" => "haiku",
63
+ "schedule" => ["07:00", "12:00", "17:00"],
64
+ "days" => %w[mon tue wed thu fri],
65
+ "skip_if_window_active" => true
66
+ },
67
+ "terminal" => {
68
+ # Empty means auto-detect. Otherwise iterm2 / tmux / none.
69
+ "backend" => nil,
70
+ "context_lines" => 80,
71
+ # Watch for a session's local CLI menus (model switch, folder
72
+ # trust) on screen and forward them to Telegram. Hooks can't see
73
+ # these — they're the CLI's own response to a human command, not
74
+ # a decision made by the agent.
75
+ "watch_menus" => true,
76
+ "menu_poll_interval" => 20,
77
+ "rate_limit_poll_interval" => 60
78
+ }
79
+ }.freeze
80
+
81
+ def self.path
82
+ base = ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
83
+ File.join(base, "agents_control", "config.yml")
84
+ end
85
+
86
+ def self.load(path = self.path)
87
+ new(read(path), path: path)
88
+ end
89
+
90
+ def self.read(path)
91
+ return {} unless File.exist?(path)
92
+
93
+ YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
94
+ rescue Psych::SyntaxError
95
+ # A broken config must not keep the daemon from starting: fall
96
+ # back to defaults and flag it in doctor.
97
+ {}
98
+ end
99
+
100
+ attr_reader :path
101
+
102
+ def initialize(data = {}, path: self.class.path)
103
+ @data = deep_merge(DEFAULTS, stringify(data))
104
+ @path = path
105
+ end
106
+
107
+ # Reads via a dotted path: config.get("anchors.model").
108
+ def get(key, default = nil)
109
+ key.to_s.split(".").reduce(@data) do |node, part|
110
+ return default unless node.is_a?(Hash) && node.key?(part)
111
+
112
+ node[part]
113
+ end
114
+ end
115
+
116
+ def set(key, value)
117
+ parts = key.to_s.split(".")
118
+ leaf = parts[0..-2].reduce(@data) { |node, part| node[part] ||= {} }
119
+ leaf[parts.last] = value
120
+ self
121
+ end
122
+
123
+ # Written through a temp file, chmod'd before the rename: the file
124
+ # may contain the allowed-chats list, and it must never be
125
+ # world-readable even for the brief moment between creation and the
126
+ # permission change landing on the final path.
127
+ def save
128
+ FileUtils.mkdir_p(File.dirname(path))
129
+ temporary = "#{path}.#{Process.pid}.tmp"
130
+
131
+ File.write(temporary, YAML.dump(@data))
132
+ File.chmod(0o600, temporary)
133
+ File.rename(temporary, path)
134
+ self
135
+ ensure
136
+ FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
137
+ end
138
+
139
+ def to_h = @data
140
+
141
+ private
142
+
143
+ # Hash#merge only copies the top level: nested hashes stay the same
144
+ # objects as in DEFAULTS. Without a deep copy, the first `set` would
145
+ # edit the defaults for every other instance at once — in the daemon
146
+ # that would mean the allowed-chats list leaking between config reloads.
147
+ def deep_merge(base, other)
148
+ result = deep_dup(base)
149
+
150
+ other.each do |key, value|
151
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
152
+ deep_merge(result[key], value)
153
+ else
154
+ deep_dup(value)
155
+ end
156
+ end
157
+
158
+ result
159
+ end
160
+
161
+ def deep_dup(object)
162
+ case object
163
+ when Hash then object.to_h { |key, value| [key, deep_dup(value)] }
164
+ when Array then object.map { |item| deep_dup(item) }
165
+ when String then object.dup
166
+ else object
167
+ end
168
+ end
169
+
170
+ def stringify(object)
171
+ case object
172
+ when Hash then object.to_h { |key, value| [key.to_s, stringify(value)] }
173
+ when Array then object.map { |item| stringify(item) }
174
+ else object
175
+ end
176
+ end
177
+ end
178
+ end