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,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# A line input with arrow-key command selection.
|
|
5
|
+
#
|
|
6
|
+
# Written by hand because reline can't do this: its own list moves
|
|
7
|
+
# with Tab and Ctrl-N, arrows are already claimed by history, and
|
|
8
|
+
# there's no way to remap them just while the list is open. And
|
|
9
|
+
# picking with arrows is exactly what you'd expect from a line that starts with a slash.
|
|
10
|
+
class Prompt
|
|
11
|
+
include Keyboard
|
|
12
|
+
|
|
13
|
+
# How many list rows to show at once.
|
|
14
|
+
WINDOW = 8
|
|
15
|
+
|
|
16
|
+
def initialize(input: $stdin, output: $stdout, completer: nil, describer: nil)
|
|
17
|
+
@in = input
|
|
18
|
+
@out = output
|
|
19
|
+
@completer = completer || ->(_word) { [] }
|
|
20
|
+
@describer = describer || ->(_item) { nil }
|
|
21
|
+
@history = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the entered line, or nil if input closed (Ctrl-D).
|
|
25
|
+
def read(prompt_text)
|
|
26
|
+
@prompt = prompt_text
|
|
27
|
+
@buffer = +""
|
|
28
|
+
@cursor = 0
|
|
29
|
+
@selected = 0
|
|
30
|
+
@dismissed = false
|
|
31
|
+
@drawn = 0
|
|
32
|
+
@history_at = @history.size
|
|
33
|
+
@result = nil
|
|
34
|
+
|
|
35
|
+
render
|
|
36
|
+
@in.raw { loop { break if handle(read_key) == :done } }
|
|
37
|
+
|
|
38
|
+
@result
|
|
39
|
+
ensure
|
|
40
|
+
finish
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# ── the loop ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
# Returns a string once input is done, and nil to keep going.
|
|
48
|
+
def handle(key)
|
|
49
|
+
# Input was closed: that's the end, not an empty line.
|
|
50
|
+
return finish_line(nil) if key.nil?
|
|
51
|
+
|
|
52
|
+
case key
|
|
53
|
+
when CTRL_C then raise Interrupt
|
|
54
|
+
when CTRL_D then @buffer.empty? ? finish_line(nil) : nil
|
|
55
|
+
when *ENTER then accept
|
|
56
|
+
when UP then move(-1)
|
|
57
|
+
when DOWN then move(+1)
|
|
58
|
+
when LEFT then step(-1)
|
|
59
|
+
when RIGHT then step(+1)
|
|
60
|
+
when TAB then take_selection
|
|
61
|
+
when ESC then dismiss
|
|
62
|
+
when *BACKSPACE then erase
|
|
63
|
+
when CTRL_A then jump(0)
|
|
64
|
+
when CTRL_E then jump(@buffer.length)
|
|
65
|
+
when CTRL_U then clear_line
|
|
66
|
+
else insert(key)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def accept
|
|
71
|
+
return take_selection if menu?
|
|
72
|
+
|
|
73
|
+
@history.push(@buffer) unless @buffer.strip.empty? || @history.last == @buffer
|
|
74
|
+
finish_line(@buffer)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Enter on a selected item fills in the command but doesn't run it:
|
|
78
|
+
# half the commands take an argument, and running without one would be a surprise.
|
|
79
|
+
def take_selection
|
|
80
|
+
return render unless menu?
|
|
81
|
+
|
|
82
|
+
@buffer = +"#{candidates[@selected]} "
|
|
83
|
+
@cursor = @buffer.length
|
|
84
|
+
@dismissed = true
|
|
85
|
+
render
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def finish_line(value = @buffer)
|
|
90
|
+
@result = value
|
|
91
|
+
:done
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ── editing the line ─────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
# Unrecognized control sequences are silently dropped: inserted into
|
|
97
|
+
# the line, they'd turn into garbage.
|
|
98
|
+
def insert(key)
|
|
99
|
+
return nil if key.length > 1 || key.ord < 0x20
|
|
100
|
+
|
|
101
|
+
@buffer.insert(@cursor, key)
|
|
102
|
+
@cursor += key.length
|
|
103
|
+
@dismissed = false
|
|
104
|
+
@selected = 0
|
|
105
|
+
render
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def erase
|
|
110
|
+
return render if @cursor.zero?
|
|
111
|
+
|
|
112
|
+
@buffer.slice!(@cursor - 1)
|
|
113
|
+
@cursor -= 1
|
|
114
|
+
@selected = 0
|
|
115
|
+
render
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def clear_line
|
|
120
|
+
@buffer = +""
|
|
121
|
+
@cursor = 0
|
|
122
|
+
render
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def step(delta)
|
|
127
|
+
@cursor = (@cursor + delta).clamp(0, @buffer.length)
|
|
128
|
+
render
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def jump(position)
|
|
133
|
+
@cursor = position
|
|
134
|
+
render
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ── list and history ──────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def move(delta)
|
|
141
|
+
return history(delta) unless menu?
|
|
142
|
+
|
|
143
|
+
@selected = (@selected + delta) % candidates.size
|
|
144
|
+
render
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def history(delta)
|
|
149
|
+
@history_at = (@history_at + delta).clamp(0, @history.size)
|
|
150
|
+
@buffer = +(@history[@history_at] || "")
|
|
151
|
+
@cursor = @buffer.length
|
|
152
|
+
render
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dismiss
|
|
157
|
+
@dismissed = true
|
|
158
|
+
render
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The list only shows for a line starting with a slash and no space
|
|
163
|
+
# yet: after that come arguments, and a hint there would just get in the way.
|
|
164
|
+
def menu?
|
|
165
|
+
return false if @dismissed || !@buffer.start_with?("/") || @buffer.include?(" ")
|
|
166
|
+
|
|
167
|
+
!candidates.empty?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A copy, not the buffer itself: the line is edited in place, and a
|
|
171
|
+
# reference to it would always equal itself — the cache would never invalidate.
|
|
172
|
+
def candidates
|
|
173
|
+
if @candidates_source != @buffer
|
|
174
|
+
@candidates_source = @buffer.dup
|
|
175
|
+
@candidates_for = Array(@completer.call(@buffer))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@candidates_for
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ── drawing ─────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def render
|
|
184
|
+
erase_drawn
|
|
185
|
+
@out.print("\r#{@prompt}#{@buffer}")
|
|
186
|
+
|
|
187
|
+
rows = menu? ? draw_menu : 0
|
|
188
|
+
@out.print("\e[#{rows}A") if rows.positive?
|
|
189
|
+
@out.print("\r\e[#{visible_width(@prompt) + @cursor}C") if visible_width(@prompt) + @cursor > 0
|
|
190
|
+
|
|
191
|
+
@drawn = rows
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def erase_drawn
|
|
196
|
+
@out.print("\r\e[J")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def draw_menu
|
|
200
|
+
window.each_with_index do |item, index|
|
|
201
|
+
line = " #{item.ljust(12)} #{@describer.call(item)}".rstrip
|
|
202
|
+
@out.print("\r\n")
|
|
203
|
+
@out.print(item == candidates[@selected] ? "\e[7m#{line}\e[0m" : line)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
window.size
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The scroll window: the selected item is always visible, even if
|
|
210
|
+
# there are more commands than fit.
|
|
211
|
+
def window
|
|
212
|
+
return candidates if candidates.size <= WINDOW
|
|
213
|
+
|
|
214
|
+
top = (@selected - WINDOW / 2).clamp(0, candidates.size - WINDOW)
|
|
215
|
+
candidates[top, WINDOW]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def visible_width(text) = text.gsub(/\e\[[0-9;]*[A-Za-z]/, "").length
|
|
219
|
+
|
|
220
|
+
def finish
|
|
221
|
+
erase_drawn
|
|
222
|
+
@out.print("\r#{@prompt}#{@buffer}\r\n")
|
|
223
|
+
@out.flush
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module AgentsControl
|
|
6
|
+
# Notices a rate-limit message on an agent's screen and types
|
|
7
|
+
# "continue" itself at the exact moment the limit resets.
|
|
8
|
+
#
|
|
9
|
+
# Three message formats:
|
|
10
|
+
#
|
|
11
|
+
# 5-hour limit reached - resets 3pm (UTC)
|
|
12
|
+
# You've hit your session limit · resets 2am (Europe/Zurich)
|
|
13
|
+
# You've hit your weekly limit · resets Oct 9, 10am
|
|
14
|
+
#
|
|
15
|
+
# Goes through the full Registry, not just tmux (unlike ScreenWatcher)
|
|
16
|
+
# — a limit doesn't hit every few seconds, so polling once a minute
|
|
17
|
+
# keeps the cost of scanning iTerm2 via AppleScript low.
|
|
18
|
+
class RateLimitWatcher
|
|
19
|
+
# "limit ... resets [at] <everything to the end of the line>".
|
|
20
|
+
PATTERN = /\blimit\b.{0,60}?\bresets?\b\s*(?:at\s+)?(.+?)\s*$/i
|
|
21
|
+
|
|
22
|
+
# A timezone in parentheses at the end of the line, if present — for
|
|
23
|
+
# display to the human. Not accounted for programmatically: Time.parse
|
|
24
|
+
# treats the time as local to the machine the daemon runs on.
|
|
25
|
+
ZONE = /\(([\w\/]+)\)\s*\z/
|
|
26
|
+
|
|
27
|
+
# A month name in the rest of the line distinguishes a bare time of
|
|
28
|
+
# day ("3pm", today or tomorrow) from a weekly-limit date ("Oct 9").
|
|
29
|
+
HAS_DATE = /[A-Za-z]{3,}\s+\d{1,2}/
|
|
30
|
+
|
|
31
|
+
GRACE = 60
|
|
32
|
+
LINES = 20
|
|
33
|
+
|
|
34
|
+
def initialize(registry:, config:, store:, api:, interval: 60, clock: -> { Time.now }, logger: nil)
|
|
35
|
+
@registry = registry
|
|
36
|
+
@config = config
|
|
37
|
+
@store = store
|
|
38
|
+
@api = api
|
|
39
|
+
@interval = interval
|
|
40
|
+
@clock = clock
|
|
41
|
+
@logger = logger
|
|
42
|
+
@running = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start
|
|
46
|
+
return self unless enabled?
|
|
47
|
+
|
|
48
|
+
@running = true
|
|
49
|
+
@thread = Thread.new { tick while @running }
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop
|
|
54
|
+
@running = false
|
|
55
|
+
@thread&.kill
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tick
|
|
59
|
+
candidates.each { |session| check(session) } if enabled?
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
log("failure: #{e.class}: #{e.message}")
|
|
62
|
+
ensure
|
|
63
|
+
sleep(@interval) if @running
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def now = @clock.call
|
|
69
|
+
|
|
70
|
+
def enabled? = @config.get("answers.auto_resume_after_limit", true)
|
|
71
|
+
|
|
72
|
+
def candidates
|
|
73
|
+
@registry.refresh.agents.reject(&:terminalless?)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A limit, once detected, isn't dropped just because the screen
|
|
79
|
+
# currently shows something else — only once it's actually fired. By
|
|
80
|
+
# reset time, the original message has almost certainly scrolled off
|
|
81
|
+
# the screen already, and reset_at has to survive exactly that.
|
|
82
|
+
def check(session)
|
|
83
|
+
spec = detect(@registry.backend_for(session).capture(session.id, lines: LINES))
|
|
84
|
+
key = resume_key(session)
|
|
85
|
+
saved = @store.get(key)
|
|
86
|
+
|
|
87
|
+
return remember(session, key, spec) if spec && (saved.nil? || saved["spec"] != spec)
|
|
88
|
+
return unless saved
|
|
89
|
+
return if saved["fired"]
|
|
90
|
+
|
|
91
|
+
fire(session, key, saved) if now >= Time.parse(saved["reset_at"]) + GRACE
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resume_key(session) = "resume:#{session.id}"
|
|
95
|
+
|
|
96
|
+
# The last textual match — the one closest to the screen's current
|
|
97
|
+
# state, not a random mention of a limit somewhere in the scrollback.
|
|
98
|
+
def detect(text)
|
|
99
|
+
text.to_s.each_line.map(&:chomp).filter_map { |line| line[PATTERN, 1] }.last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remember(session, key, spec)
|
|
103
|
+
reset_at = parse_reset_time(spec)
|
|
104
|
+
return notify_unparseable(session, spec) unless reset_at
|
|
105
|
+
|
|
106
|
+
@store.put({ "spec" => spec, "reset_at" => reset_at.iso8601, "fired" => false },
|
|
107
|
+
ttl: 86_400, key: key)
|
|
108
|
+
notify_detected(session, spec, reset_at)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_reset_time(spec)
|
|
112
|
+
zone = spec[ZONE, 1]
|
|
113
|
+
clean = spec.sub(ZONE, "").strip
|
|
114
|
+
return nil if clean.empty?
|
|
115
|
+
|
|
116
|
+
time = Time.parse(clean, now)
|
|
117
|
+
|
|
118
|
+
# A bare time of day in the past means "tomorrow"; a weekly-limit
|
|
119
|
+
# date in the past means "a year has rolled over," not "the reset
|
|
120
|
+
# was yesterday."
|
|
121
|
+
if time < now
|
|
122
|
+
time += clean.match?(HAS_DATE) ? 365 * 86_400 : 86_400
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
time
|
|
126
|
+
rescue ArgumentError, TypeError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The session could have closed between detecting the limit and the
|
|
131
|
+
# reset moment — checked right before typing, not trusted from the
|
|
132
|
+
# state at detection time.
|
|
133
|
+
def fire(session, key, saved)
|
|
134
|
+
fresh = @registry.refresh.find(session.id)
|
|
135
|
+
|
|
136
|
+
unless fresh&.agent?
|
|
137
|
+
@store.delete(key)
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
ok = type_resume(fresh)
|
|
142
|
+
@store.put(saved.merge("fired" => true), ttl: 3600, key: key)
|
|
143
|
+
|
|
144
|
+
broadcast("✅ #{fresh.label}: the limit reset, sent \"#{resume_message}\".", session: fresh) if ok
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def type_resume(session)
|
|
148
|
+
backend = @registry.backend_for(session)
|
|
149
|
+
backend.send_text(session.id, resume_message, newline: false) &&
|
|
150
|
+
sleep(0.4).then { backend.send_text(session.id, "", newline: true) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resume_message
|
|
154
|
+
@config.get("answers.resume_message",
|
|
155
|
+
"Continue where you left off — the previous attempt was rate limited.")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def notify_detected(session, spec, reset_at)
|
|
159
|
+
broadcast("⏳ #{session.label} hit a limit: \"#{spec}\".\n" \
|
|
160
|
+
"Resets at #{reset_at.strftime('%H:%M %d.%m')} — I'll send \"#{resume_message}\" myself.",
|
|
161
|
+
session: session)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Don't spam the same unparseable message on every tick.
|
|
165
|
+
def notify_unparseable(session, spec)
|
|
166
|
+
key = "#{resume_key(session)}:unparsed"
|
|
167
|
+
return if @store.get(key) == spec
|
|
168
|
+
|
|
169
|
+
@store.put(spec, ttl: 3600, key: key)
|
|
170
|
+
broadcast("⚠️ #{session.label} looks like it hit a limit, but I couldn't parse the reset time: \"#{spec}\".\n" \
|
|
171
|
+
"Answer manually whenever you see fit.", session: session)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def broadcast(text, session: nil)
|
|
175
|
+
chats.each do |chat_id|
|
|
176
|
+
sent = @api.send_message(chat_id: chat_id, text: text)
|
|
177
|
+
remember_reply(chat_id, sent, session) if session
|
|
178
|
+
rescue StandardError
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def remember_reply(chat_id, sent, session)
|
|
184
|
+
id = sent.is_a?(Hash) ? sent["message_id"] : nil
|
|
185
|
+
return unless id
|
|
186
|
+
|
|
187
|
+
@store.put({ "session_id" => session.id, "cwd" => session.cwd, "label" => session.label },
|
|
188
|
+
ttl: 30 * 86_400, key: "reply:#{chat_id}:#{id}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def chats = Array(@config.get("telegram.allowed_chat_ids", []))
|
|
192
|
+
|
|
193
|
+
# log() catches any write failure itself: an exception raised inside
|
|
194
|
+
# a rescue isn't caught by that same rescue, and would kill the
|
|
195
|
+
# thread for good.
|
|
196
|
+
def log(message)
|
|
197
|
+
@logger&.puts("[rate-limit-watcher] #{message}")
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# A unified session list assembled from disparate sources.
|
|
5
|
+
#
|
|
6
|
+
# There are three sources, and they complement each other:
|
|
7
|
+
#
|
|
8
|
+
# 1. terminal backends — iTerm2 tabs and tmux panes;
|
|
9
|
+
# 2. the process tree — who's actually alive in those tabs;
|
|
10
|
+
# 3. terminalless agent processes — VS Code sessions, which have no tab.
|
|
11
|
+
#
|
|
12
|
+
# Source (1) without (2) lies: a tab's title stays up after the agent
|
|
13
|
+
# exits. Source (3) without special handling just gets lost — VS Code
|
|
14
|
+
# sessions aren't a rare edge case, they're often the majority of active
|
|
15
|
+
# sessions.
|
|
16
|
+
class Registry
|
|
17
|
+
def initialize(backends: nil, probe: nil, executor: Executor.new)
|
|
18
|
+
@executor = executor
|
|
19
|
+
@backends = backends || default_backends
|
|
20
|
+
@probe = probe || ProcessProbe.new(executor: executor)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Every session: terminal tabs plus terminalless agents.
|
|
24
|
+
def sessions
|
|
25
|
+
@sessions ||= begin
|
|
26
|
+
probe.refresh
|
|
27
|
+
enrich(terminal_sessions) + terminalless_sessions
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Only sessions with a confirmed live agent.
|
|
32
|
+
# This is the list that goes to Telegram by default.
|
|
33
|
+
def agents = sessions.select(&:agent?)
|
|
34
|
+
|
|
35
|
+
def find(id) = sessions.find { |session| session.id == id }
|
|
36
|
+
|
|
37
|
+
# The backend that can control this session.
|
|
38
|
+
def backend_for(session)
|
|
39
|
+
backends.find { |backend| backend.name == session.backend } || null_backend
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Drop the cache — the registry is rebuilt on demand, not held in memory.
|
|
43
|
+
def refresh
|
|
44
|
+
@sessions = nil
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def available_backends = backends.select(&:available?)
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :backends, :probe, :executor
|
|
53
|
+
|
|
54
|
+
# The probe is shared: the process tree is read once per registry pass,
|
|
55
|
+
# not separately by each backend.
|
|
56
|
+
def default_backends
|
|
57
|
+
[
|
|
58
|
+
Terminals::ITerm2.new(executor: executor, probe: probe),
|
|
59
|
+
Terminals::Tmux.new(executor: executor, probe: probe)
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def null_backend
|
|
64
|
+
@null_backend ||= Terminals::Null.new(executor: executor)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def terminal_sessions
|
|
68
|
+
available_backends.flat_map do |backend|
|
|
69
|
+
backend.sessions
|
|
70
|
+
rescue StandardError
|
|
71
|
+
# One backend failing must not take down the whole list: without
|
|
72
|
+
# iTerm2 we can still show tmux panes.
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The tab title is already available at this point, but it can't be
|
|
78
|
+
# trusted — only the process tree confirms an agent.
|
|
79
|
+
def enrich(list)
|
|
80
|
+
list.map do |session|
|
|
81
|
+
agent = probe.agent_in(session.tty)
|
|
82
|
+
foreground = probe.foreground(session.tty)
|
|
83
|
+
|
|
84
|
+
session.with(
|
|
85
|
+
agent: agent&.agent,
|
|
86
|
+
agent_pid: agent&.pid,
|
|
87
|
+
foreground_command: foreground&.name
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A tabless session has neither a title nor a cwd from the terminal,
|
|
93
|
+
# so the directory comes from the process itself — otherwise the list
|
|
94
|
+
# would show three nameless rows.
|
|
95
|
+
def terminalless_sessions
|
|
96
|
+
found = probe.terminalless_agents
|
|
97
|
+
return [] if found.empty?
|
|
98
|
+
|
|
99
|
+
paths = probe.cwds(found.map(&:pid))
|
|
100
|
+
|
|
101
|
+
found.map do |process|
|
|
102
|
+
Session.new(
|
|
103
|
+
id: "pid:#{process.pid}",
|
|
104
|
+
backend: nil,
|
|
105
|
+
tty: nil,
|
|
106
|
+
title: nil,
|
|
107
|
+
cwd: paths[process.pid],
|
|
108
|
+
agent: process.agent,
|
|
109
|
+
agent_pid: process.pid,
|
|
110
|
+
foreground_command: process.name
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# A human's answer to an event.
|
|
5
|
+
#
|
|
6
|
+
# Also agent-neutral: turning it into the right JSON shape is the
|
|
7
|
+
# adapter's job. Telegram only ever knows these four kinds.
|
|
8
|
+
class Reply
|
|
9
|
+
KINDS = %i[allow deny text none].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :kind, :text, :remember
|
|
12
|
+
|
|
13
|
+
def self.allow(remember: false) = new(kind: :allow, remember: remember)
|
|
14
|
+
def self.deny(text = nil) = new(kind: :deny, text: text)
|
|
15
|
+
def self.text(value) = new(kind: :text, text: value)
|
|
16
|
+
|
|
17
|
+
# Nobody answered. A distinct kind rather than nil: silence is itself
|
|
18
|
+
# a decision, and it's treated as a refusal — but it's still worth
|
|
19
|
+
# distinguishing from an explicit "no" in the message shown to the user.
|
|
20
|
+
def self.none = new(kind: :none)
|
|
21
|
+
|
|
22
|
+
def initialize(kind:, text: nil, remember: false)
|
|
23
|
+
raise ArgumentError, "unknown reply kind: #{kind}" unless KINDS.include?(kind)
|
|
24
|
+
|
|
25
|
+
@kind = kind
|
|
26
|
+
@text = text
|
|
27
|
+
@remember = remember
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def allow? = kind == :allow
|
|
31
|
+
def deny? = kind == :deny
|
|
32
|
+
def text? = kind == :text
|
|
33
|
+
def none? = kind == :none
|
|
34
|
+
|
|
35
|
+
# Whether to allow the tool. Silence is not permission: if nobody
|
|
36
|
+
# answered while the owner was out, the action doesn't happen.
|
|
37
|
+
def permits? = allow?
|
|
38
|
+
|
|
39
|
+
def to_h = { kind: kind, text: text, remember: remember }
|
|
40
|
+
end
|
|
41
|
+
end
|