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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "securerandom"
6
+ require "open3"
7
+
8
+ module AgentsControl
9
+ module Hooks
10
+ # Receiver for events from agents.
11
+ #
12
+ # HTTP over bare sockets, not webrick: it left the stdlib in Ruby 3,
13
+ # and pulling in a gem just to accept local POSTs isn't worth it. The
14
+ # subset of the protocol needed — one request line, headers, a body
15
+ # — fits in about fifty lines.
16
+ #
17
+ # Listens strictly on 127.0.0.1. The port must never be exposed
18
+ # externally under any circumstances: anyone who could reach it
19
+ # could grant the agent permission to run commands.
20
+ #
21
+ # The handler holds the connection open until a human answers — this
22
+ # is exactly how a hook blocks the agent. So every connection gets
23
+ # its own thread, and there can be as many as there are sessions
24
+ # waiting at once.
25
+ class Server
26
+ HOST = "127.0.0.1"
27
+
28
+ attr_reader :port, :secret
29
+
30
+ def initialize(port: 0, secret: nil, logger: nil)
31
+ @requested_port = port
32
+ @secret = secret || SecureRandom.hex(16)
33
+ @logger = logger
34
+ @running = false
35
+ end
36
+
37
+ # The port is taken almost always for one reason: another
38
+ # agents_control is already running. Same situation as a 409 from
39
+ # Telegram — it won't resolve itself, and a raw backtrace explains nothing here.
40
+ class PortBusy < AgentsControl::Error; end
41
+
42
+ def start(&handler)
43
+ @server = TCPServer.new(HOST, @requested_port)
44
+ @port = @server.addr[1]
45
+ @running = true
46
+
47
+ @thread = Thread.new do
48
+ accept_loop(&handler)
49
+ end
50
+
51
+ self
52
+ rescue Errno::EADDRINUSE
53
+ raise PortBusy, "port #{@requested_port} is taken#{occupant_hint}"
54
+ end
55
+
56
+ # "Something's already running somewhere" with no indication of
57
+ # where is a useless message: finding the process becomes a manual
58
+ # hunt. Name it right away.
59
+ def occupant_hint
60
+ # argv array, not a shell string: the port ultimately comes from
61
+ # the user's own config, but there's no reason to trust a shell
62
+ # to parse it correctly either.
63
+ out, = Open3.capture3("lsof", "-nP", "-iTCP:#{@requested_port.to_i}", "-sTCP:LISTEN", "-t")
64
+ pids = out.split.map(&:strip)
65
+ return "" if pids.empty?
66
+
67
+ " by process #{pids.join(', ')}. Stop it: agents_control stop"
68
+ rescue StandardError
69
+ ""
70
+ end
71
+
72
+ def url = "http://#{HOST}:#{port}"
73
+
74
+ def stop
75
+ @running = false
76
+ @server&.close
77
+ @thread&.kill
78
+ end
79
+
80
+ def wait = @thread&.join
81
+
82
+ private
83
+
84
+ def accept_loop(&handler)
85
+ while @running
86
+ begin
87
+ socket = @server.accept
88
+ rescue IOError, Errno::EBADF
89
+ break # the socket was closed by stop
90
+ end
91
+
92
+ Thread.new(socket) { |connection| serve(connection, &handler) }
93
+ end
94
+ end
95
+
96
+ def serve(socket, &handler)
97
+ request = read_request(socket)
98
+ return respond(socket, 400, {}) unless request
99
+
100
+ return respond(socket, 403, {}) if from_browser?(request) || !authorized?(request)
101
+
102
+ result = handler.call(request[:path], request[:body])
103
+ respond(socket, 200, result || {})
104
+ rescue StandardError => e
105
+ log("handling failed: #{e.class}: #{e.message}")
106
+ # An empty response means "no decision" — the agent continues as usual.
107
+ # A failure on our end must not stop it.
108
+ respond(socket, 200, {})
109
+ ensure
110
+ socket.close unless socket.closed?
111
+ end
112
+
113
+ def read_request(socket)
114
+ request_line = socket.gets
115
+ return nil if request_line.nil?
116
+
117
+ path = request_line.split[1].to_s
118
+ headers = read_headers(socket)
119
+ length = headers["content-length"].to_i
120
+ body = length.positive? ? socket.read(length).to_s : ""
121
+
122
+ { path: path, headers: headers, body: parse(body) }
123
+ end
124
+
125
+ def read_headers(socket)
126
+ headers = {}
127
+
128
+ while (line = socket.gets) && line != "\r\n"
129
+ key, value = line.split(":", 2)
130
+ headers[key.to_s.strip.downcase] = value.to_s.strip
131
+ end
132
+
133
+ headers
134
+ end
135
+
136
+ def parse(body)
137
+ body.empty? ? {} : JSON.parse(body)
138
+ rescue JSON::ParserError
139
+ {}
140
+ end
141
+
142
+ # The only thing that could reach the local port from outside the
143
+ # machine is a page open in a browser: nothing stops it from
144
+ # sending requests to 127.0.0.1. It doesn't know the secret, but a
145
+ # cheap extra line of defense is still worth having.
146
+ #
147
+ # The browser sets the Origin header itself and a page can't forge
148
+ # it. No agent ever sends it, so its presence is a reliable sign of
149
+ # an outsider. Host is checked separately: this rules out DNS
150
+ # spoofing, where a page addresses a name that resolves to loopback.
151
+ def from_browser?(request)
152
+ headers = request[:headers]
153
+ return true if headers.key?("origin")
154
+
155
+ host = headers["host"].to_s.split(":").first
156
+
157
+ !["127.0.0.1", "localhost", "", nil].include?(host)
158
+ end
159
+
160
+ # A shared secret filters out stray requests from other local
161
+ # programs. It's not protection against someone already logged in
162
+ # as the same user — the only thing defending against that is the
163
+ # port being local at all.
164
+ def authorized?(request)
165
+ return true if @secret.nil?
166
+
167
+ provided = request[:headers]["authorization"].to_s.sub(/\ABearer\s+/i, "")
168
+
169
+ # Constant-time comparison: the secret's length is already
170
+ # known, but there's no reason to leak anything through an early
171
+ # byte mismatch either.
172
+ secure_compare(provided, @secret)
173
+ end
174
+
175
+ def secure_compare(given, expected)
176
+ return false unless given.bytesize == expected.bytesize
177
+
178
+ given.bytes.zip(expected.bytes).reduce(0) { |diff, (a, b)| diff | (a ^ b) }.zero?
179
+ end
180
+
181
+ def respond(socket, status, payload)
182
+ body = JSON.generate(payload)
183
+
184
+ socket.print(
185
+ "HTTP/1.1 #{status} #{status == 200 ? 'OK' : 'Error'}\r\n" \
186
+ "Content-Type: application/json\r\n" \
187
+ "Content-Length: #{body.bytesize}\r\n" \
188
+ "Connection: close\r\n\r\n#{body}"
189
+ )
190
+ rescue Errno::EPIPE, Errno::ECONNRESET
191
+ # The agent gave up without waiting. Happens — not our concern.
192
+ end
193
+
194
+ def log(message) = @logger&.puts("[hooks] #{message}")
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module AgentsControl
6
+ # Reading keystrokes from the terminal in raw mode.
7
+ #
8
+ # Split out on its own because two things need the keyboard: the input
9
+ # line with its command palette, and the settings menu. Parsing escape
10
+ # sequences and multi-byte characters is a place it's easy to get wrong twice.
11
+ module Keyboard
12
+ UP = "\e[A"
13
+ DOWN = "\e[B"
14
+ RIGHT = "\e[C"
15
+ LEFT = "\e[D"
16
+
17
+ ENTER = ["\r", "\n"].freeze
18
+ BACKSPACE = ["\x7F", "\b"].freeze
19
+ CTRL_C = "\x03"
20
+ CTRL_D = "\x04"
21
+ CTRL_A = "\x01"
22
+ CTRL_E = "\x05"
23
+ CTRL_U = "\x15"
24
+ TAB = "\t"
25
+ ESC = "\e"
26
+
27
+ private
28
+
29
+ # Returns a key as a string: a printable character, a control byte,
30
+ # or a whole escape sequence. nil means end of input.
31
+ def read_key
32
+ byte = @in.getbyte
33
+ return nil if byte.nil?
34
+
35
+ return read_escape if byte == 0x1B
36
+
37
+ read_utf8(byte)
38
+ end
39
+
40
+ # A lone Esc and an arrow key start the same way. Told apart by
41
+ # waiting: a sequence's continuation arrives immediately, a lone Esc arrives alone.
42
+ #
43
+ # The end of a sequence is determined by its structure, not by a
44
+ # pause. Otherwise on fast input — and in tests, where there are no
45
+ # pauses at all — an arrow key would swallow the next keystrokes, and they'd be lost.
46
+ def read_escape
47
+ return ESC unless pending?(0.05)
48
+
49
+ second = @in.getbyte
50
+ return ESC if second.nil?
51
+
52
+ sequence = +"#{ESC}#{second.chr}"
53
+ return sequence unless ["[", "O"].include?(second.chr)
54
+
55
+ # A CSI sequence ends on a byte in the 0x40..0x7E range — that's the stop condition.
56
+ while sequence.length < 12
57
+ byte = @in.getbyte
58
+ break if byte.nil?
59
+
60
+ sequence << byte.chr
61
+ break if (0x40..0x7E).cover?(byte)
62
+ end
63
+
64
+ sequence
65
+ end
66
+
67
+ def pending?(timeout)
68
+ return !@in.wait_readable(timeout).nil? if @in.respond_to?(:wait_readable)
69
+
70
+ !IO.select([@in], nil, nil, timeout).nil?
71
+ rescue TypeError, IOError
72
+ true
73
+ end
74
+
75
+ # Non-ASCII characters arrive as several bytes — assembled into a
76
+ # whole character, or the string would come apart into fragments.
77
+ def read_utf8(first)
78
+ length = case first
79
+ when 0x00..0x7F then 1
80
+ when 0xC0..0xDF then 2
81
+ when 0xE0..0xEF then 3
82
+ else 4
83
+ end
84
+
85
+ bytes = [first]
86
+ (length - 1).times { bytes << @in.getbyte.to_i }
87
+ bytes.pack("C*").force_encoding(Encoding::UTF_8).scrub
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # A list navigated with arrow keys.
5
+ #
6
+ # Needed wherever picking is more natural than typing: settings are
7
+ # shown with human-readable names, and requiring the internal name
8
+ # instead would be a trap. See a row, hit Enter, it changes in place.
9
+ class Menu
10
+ include Keyboard
11
+
12
+ HINT = "↑↓ — select · Enter — toggle · Esc — exit"
13
+
14
+ def initialize(input: $stdin, output: $stdout)
15
+ @in = input
16
+ @out = output
17
+ end
18
+
19
+ # rows — something callable: the row list is re-read after every
20
+ # change, or the screen would keep showing the old value.
21
+ #
22
+ # The block receives the index of the selected row.
23
+ def run(title:, rows:)
24
+ @rows = rows
25
+ @items = nil
26
+ @selected = 0
27
+ @drawn = 0
28
+
29
+ @in.raw do
30
+ loop do
31
+ draw(title)
32
+ break unless step(read_key) { |index| yield(index) }
33
+ end
34
+ end
35
+
36
+ nil
37
+ ensure
38
+ finish
39
+ end
40
+
41
+ private
42
+
43
+ # Returns false when it's time to close the menu.
44
+ def step(key)
45
+ case key
46
+ # "й" is the same physical key as "q" on a ЙЦУКЕН layout — quits
47
+ # without forcing a layout switch just to press q.
48
+ when nil, ESC, CTRL_C, CTRL_D, "q", "й" then return false
49
+ when UP then move(-1)
50
+ when DOWN then move(+1)
51
+ when *ENTER then activate { |index| yield(index) }
52
+ end
53
+
54
+ true
55
+ end
56
+
57
+ def move(delta)
58
+ return if items.empty?
59
+
60
+ @selected = (@selected + delta) % items.size
61
+ end
62
+
63
+ def activate
64
+ return if items.empty?
65
+
66
+ yield(@selected)
67
+ # Values changed — re-read the list, or the screen would keep showing the old one.
68
+ @items = nil
69
+ end
70
+
71
+ def items
72
+ @items ||= Array(@rows.call)
73
+ end
74
+
75
+ def draw(title)
76
+ rewind
77
+ lines = [title, ""] + rendered_items + ["", HINT]
78
+ @out.print(lines.join("\r\n"))
79
+ @drawn = lines.size - 1
80
+ end
81
+
82
+ def rendered_items
83
+ items.each_with_index.map do |line, index|
84
+ index == @selected ? "\e[7m #{line} \e[0m" : " #{line} "
85
+ end
86
+ end
87
+
88
+ # Rewind to the start of what was drawn and clear it: redrawn in
89
+ # full, so there's no need to track each line's length separately.
90
+ def rewind
91
+ @out.print("\e[#{@drawn}A") if @drawn.positive?
92
+ @out.print("\r\e[J")
93
+ end
94
+
95
+ def finish
96
+ rewind
97
+ @out.flush
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Questions currently waiting for an answer.
5
+ #
6
+ # The thread serving the hook parks here until a human presses a
7
+ # button in Telegram, or time runs out. This wait is exactly what
8
+ # holds the agent blocked — that's the whole point of it.
9
+ #
10
+ # Held only in memory, and that's deliberate. If the daemon crashes,
11
+ # the HTTP connection to the agent drops too: the agent gets a network
12
+ # error, treats it as "no decision," and continues on its own. There's
13
+ # nothing to restore after a restart — nobody's left waiting.
14
+ class Pending
15
+ Question = Struct.new(:id, :event, :queue, :asked_at, keyword_init: true)
16
+
17
+ def initialize
18
+ @questions = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Register a question and wait for an answer.
23
+ # Returns Reply.none if nobody answered in time.
24
+ #
25
+ # The block runs between registration and waiting: the message has
26
+ # to be sent once the question's identifier is already known, but
27
+ # before the thread goes to sleep. Otherwise the answer could arrive
28
+ # before we start waiting for it.
29
+ def ask(event, timeout:)
30
+ question = register(event)
31
+
32
+ begin
33
+ yield(question.id) if block_given?
34
+ question.queue.pop(timeout: timeout) || Reply.none
35
+ ensure
36
+ forget(question.id)
37
+ end
38
+ end
39
+
40
+ def answer(id, reply)
41
+ question = @mutex.synchronize { @questions[id] }
42
+ return false unless question
43
+
44
+ question.queue.push(reply)
45
+ true
46
+ end
47
+
48
+ def find(id) = @mutex.synchronize { @questions[id] }
49
+
50
+ def all = @mutex.synchronize { @questions.values.dup }
51
+
52
+ def size = @mutex.synchronize { @questions.size }
53
+
54
+ private
55
+
56
+ def register(event)
57
+ question = Question.new(
58
+ id: SecureRandom.alphanumeric(8).downcase,
59
+ event: event,
60
+ queue: Thread::Queue.new,
61
+ asked_at: Time.now
62
+ )
63
+
64
+ @mutex.synchronize { @questions[question.id] = question }
65
+ question
66
+ end
67
+
68
+ def forget(id) = @mutex.synchronize { @questions.delete(id) }
69
+ end
70
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Who's actually running in each tab.
5
+ #
6
+ # A tab's title can't be trusted: Claude Code sets it via an OSC
7
+ # sequence, and the title stays up after the process exits.
8
+ #
9
+ # One `ps -A` call for the whole process tree instead of a separate
10
+ # `ps -t` per tab.
11
+ class ProcessProbe
12
+ # Processes with no controlling terminal. This is what an agent
13
+ # session launched from the VS Code extension looks like: it has no
14
+ # tab at all.
15
+ NO_TTY = "??"
16
+
17
+ # An agent is identified by the basename of its executable: in a
18
+ # terminal that's `claude`; in VS Code it's a long path into the
19
+ # extension's directory.
20
+ AGENT_BINARIES = {
21
+ "claude" => :claude_code,
22
+ "codex" => :codex
23
+ }.freeze
24
+
25
+ Process = Struct.new(:tty, :pid, :stat, :command, keyword_init: true) do
26
+ # `+` in the stat column marks a process in the foreground group —
27
+ # i.e. what the user is currently interacting with.
28
+ def foreground? = stat.to_s.include?("+")
29
+ def name = File.basename(command.to_s)
30
+ def agent = AGENT_BINARIES[name]
31
+ def agent? = !agent.nil?
32
+ def terminal? = tty != NO_TTY
33
+ end
34
+
35
+ def initialize(executor: Executor.new)
36
+ @executor = executor
37
+ end
38
+
39
+ # Re-reads the process tree. The result is cached on the object — one
40
+ # probe per registry pass.
41
+ def refresh
42
+ result = @executor.run(Which.find!("ps"), "-A", "-o", "tty=,pid=,stat=,comm=")
43
+ @processes = result.success? ? parse(result.stdout) : []
44
+ self
45
+ end
46
+
47
+ def processes
48
+ @processes || refresh.processes
49
+ end
50
+
51
+ # A tab's processes, in launch order.
52
+ def for_tty(tty)
53
+ by_tty.fetch(normalize(tty), [])
54
+ end
55
+
56
+ # What's currently in the foreground of a tab. This is what the user sees.
57
+ def foreground(tty)
58
+ candidates = for_tty(tty)
59
+ candidates.reverse.find(&:foreground?) || candidates.last
60
+ end
61
+
62
+ # The live agent in a tab — or nil, whatever the title claims.
63
+ def agent_in(tty)
64
+ for_tty(tty).find(&:agent?)
65
+ end
66
+
67
+ # Whether an app with this name is running.
68
+ #
69
+ # Deliberately not via pgrep: on macOS `pgrep -x` matches against the
70
+ # full path rather than the name, and in a restricted environment
71
+ # pgrep can miss processes that ps sees fine. We already have the
72
+ # list — this is a filter over existing data, not another call.
73
+ def running?(name)
74
+ processes.any? { |process| process.name == name }
75
+ end
76
+
77
+ # Agents with no tab: VS Code sessions and anything else terminalless.
78
+ def terminalless_agents
79
+ processes.select { |process| process.agent? && !process.terminal? }
80
+ end
81
+
82
+ # Working directories of processes: { pid => path }.
83
+ #
84
+ # For a tabless session this is the only way to give it a human name —
85
+ # there's no title and no cwd from a terminal. One lsof call for all
86
+ # pids at once (0.027s), not one call per pid.
87
+ def cwds(pids)
88
+ pids = Array(pids).compact.uniq
89
+ return {} if pids.empty?
90
+
91
+ lsof = Which.find("lsof")
92
+ return {} unless lsof
93
+
94
+ result = @executor.run(lsof, "-a", "-d", "cwd", "-p", pids.join(","), "-Fn")
95
+ # We may lack permission for someone else's processes — that's fine, return what we have.
96
+ result.success? ? parse_lsof(result.stdout) : {}
97
+ end
98
+
99
+ private
100
+
101
+ # -F format: lines with a single-letter prefix. `p` is pid, `n` is
102
+ # filename. An `n` value belongs to the most recently seen `p`.
103
+ def parse_lsof(output)
104
+ current = nil
105
+
106
+ output.each_line.with_object({}) do |line, found|
107
+ line = line.chomp
108
+ case line[0]
109
+ when "p" then current = line[1..].to_i
110
+ when "n" then found[current] = line[1..] if current && !found.key?(current)
111
+ end
112
+ end
113
+ end
114
+
115
+ def by_tty
116
+ @by_tty ||= processes.group_by(&:tty)
117
+ end
118
+
119
+ # macOS prints `ttys017`, but elsewhere the same tty is called
120
+ # `/dev/ttys017` or `s017` — normalize to one form.
121
+ def normalize(tty)
122
+ return NO_TTY if tty.nil? || tty.empty?
123
+
124
+ tty.sub(%r{\A/dev/}, "")
125
+ end
126
+
127
+ def parse(output)
128
+ output.each_line.filter_map do |line|
129
+ tty, pid, stat, command = line.strip.split(/\s+/, 4)
130
+ next if command.nil?
131
+
132
+ Process.new(tty: normalize(tty), pid: pid.to_i, stat: stat, command: command)
133
+ end
134
+ end
135
+ end
136
+ end