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,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
|