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