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,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module AgentsControl
|
|
6
|
+
# Notices Claude Code's local menus on a session's screen.
|
|
7
|
+
#
|
|
8
|
+
# Dialogs the CLI shows itself in response to a local human command —
|
|
9
|
+
# switching models via /model, folder trust on first launch — never
|
|
10
|
+
# produce a hook event at all. The only way to see this state is to
|
|
11
|
+
# read the screen itself.
|
|
12
|
+
#
|
|
13
|
+
# Goes through the full Registry — sees bare iTerm2 tabs too, not just
|
|
14
|
+
# tmux panes, same as RateLimitWatcher.
|
|
15
|
+
#
|
|
16
|
+
# The heuristic is narrow: consecutive lines "❯ 1. …" and " 2. …" —
|
|
17
|
+
# this is how Claude Code draws any choice (folder trust, model switch,
|
|
18
|
+
# a tool permission if it ever made it to the screen), and almost
|
|
19
|
+
# nothing else draws like this.
|
|
20
|
+
class ScreenWatcher
|
|
21
|
+
# "1." next to the cursor marks the first option — the start of a menu.
|
|
22
|
+
CURSOR_OPTION = /^\s*❯\s*1\.\s+(.+?)\s*$/
|
|
23
|
+
|
|
24
|
+
# Later options come without the cursor, just numbered.
|
|
25
|
+
OPTION = /^\s*(\d+)\.\s+(.+?)\s*$/
|
|
26
|
+
|
|
27
|
+
# A 30-line screen comfortably covers any CLI dialog worth catching —
|
|
28
|
+
# Claude Code doesn't draw menus that long.
|
|
29
|
+
LINES = 30
|
|
30
|
+
|
|
31
|
+
def initialize(registry:, config:, store:, api:, logger: nil)
|
|
32
|
+
@registry = registry
|
|
33
|
+
@config = config
|
|
34
|
+
@store = store
|
|
35
|
+
@api = api
|
|
36
|
+
@logger = logger
|
|
37
|
+
@running = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start
|
|
41
|
+
return self unless enabled?
|
|
42
|
+
|
|
43
|
+
@running = true
|
|
44
|
+
@thread = Thread.new do
|
|
45
|
+
tick while @running
|
|
46
|
+
ensure
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop
|
|
54
|
+
@running = false
|
|
55
|
+
@thread&.kill
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# One pass over all agent sessions. Public method — tests call it
|
|
59
|
+
# directly, without spinning up a real background thread.
|
|
60
|
+
def tick
|
|
61
|
+
candidates.each { |session| check(session) }
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
log("failure: #{e.class}: #{e.message}")
|
|
64
|
+
ensure
|
|
65
|
+
sleep(interval) if @running
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def enabled? = @config.get("terminal.watch_menus", true)
|
|
71
|
+
|
|
72
|
+
def interval = @config.get("terminal.menu_poll_interval", 20)
|
|
73
|
+
|
|
74
|
+
def candidates
|
|
75
|
+
@registry.refresh.agents.reject(&:terminalless?)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check(session)
|
|
81
|
+
options = parse_menu(@registry.backend_for(session).capture(session.id, lines: LINES))
|
|
82
|
+
key = menu_key(session)
|
|
83
|
+
|
|
84
|
+
return @store.delete(key) if options.empty?
|
|
85
|
+
|
|
86
|
+
digest = Digest::SHA1.hexdigest(options.join("\n"))
|
|
87
|
+
return if @store.get(key) == digest # the same dialog — already notified
|
|
88
|
+
|
|
89
|
+
@store.put(digest, ttl: 3600, key: key)
|
|
90
|
+
notify(session, options)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def menu_key(session) = "menu:#{session.id}"
|
|
94
|
+
|
|
95
|
+
# Looks for the last textual occurrence of "❯ 1. …" — the one
|
|
96
|
+
# closest to the screen's current state, not a random numbered list
|
|
97
|
+
# left over somewhere higher in the scrollback.
|
|
98
|
+
def parse_menu(text)
|
|
99
|
+
lines = text.to_s.each_line.map(&:chomp)
|
|
100
|
+
start = lines.rindex { |line| line.match?(CURSOR_OPTION) }
|
|
101
|
+
return [] unless start
|
|
102
|
+
|
|
103
|
+
options = [lines[start][CURSOR_OPTION, 1]]
|
|
104
|
+
index = start + 1
|
|
105
|
+
|
|
106
|
+
while index < lines.size && (m = lines[index].match(OPTION)) && m[1].to_i == options.size + 1
|
|
107
|
+
options << m[2]
|
|
108
|
+
index += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
options.size >= 2 ? options : []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def notify(session, options)
|
|
115
|
+
text = "🖥 #{session.label} is waiting for a choice in the terminal:\n\n" +
|
|
116
|
+
options.each_with_index.map { |option, i| "#{i + 1}. #{option}" }.join("\n")
|
|
117
|
+
|
|
118
|
+
chats.each do |chat_id|
|
|
119
|
+
sent = @api.send_message(chat_id: chat_id, text: text, reply_markup: buttons(session, options))
|
|
120
|
+
remember_reply(chat_id, sent, session)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
# One unreachable chat must not affect the rest.
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def remember_reply(chat_id, sent, session)
|
|
128
|
+
id = sent.is_a?(Hash) ? sent["message_id"] : nil
|
|
129
|
+
return unless id
|
|
130
|
+
|
|
131
|
+
@store.put({ "session_id" => session.id, "cwd" => session.cwd, "label" => session.label },
|
|
132
|
+
ttl: 30 * 86_400, key: "reply:#{chat_id}:#{id}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def buttons(session, options)
|
|
136
|
+
rows = options.each_with_index.map do |option, i|
|
|
137
|
+
[{
|
|
138
|
+
text: "#{i + 1}. #{option}"[0, 60],
|
|
139
|
+
callback_data: @store.put({ "action" => "menu_choice", "session_id" => session.id,
|
|
140
|
+
"choice" => i + 1 }, ttl: 3600)
|
|
141
|
+
}]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
{ inline_keyboard: rows }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def chats = Array(@config.get("telegram.allowed_chat_ids", []))
|
|
148
|
+
|
|
149
|
+
# log() catches any write failure itself: an exception raised inside
|
|
150
|
+
# a rescue isn't caught by that same rescue, and would kill the
|
|
151
|
+
# thread for good.
|
|
152
|
+
def log(message)
|
|
153
|
+
@logger&.puts("[screen-watcher] #{message}")
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
# Storing tokens outside the repo and outside the config.
|
|
8
|
+
#
|
|
9
|
+
# Providers are tried in order; the first available one that answers
|
|
10
|
+
# wins. Implemented by shelling out to system binaries rather than gems:
|
|
11
|
+
# gems like keyring pull in C extensions and break installation for
|
|
12
|
+
# some users, while `security` and `secret-tool` are always present on
|
|
13
|
+
# their respective platforms.
|
|
14
|
+
#
|
|
15
|
+
# The rule that shaped the implementation: **the secret must never land
|
|
16
|
+
# in argv**. Anything passed as an argument is visible in `ps` to any
|
|
17
|
+
# process the user owns, and it lands in shell history. That's why
|
|
18
|
+
# writing to the Keychain goes through `security -i` with the command
|
|
19
|
+
# on stdin, and the CLI never accepts the token as a flag.
|
|
20
|
+
class Secrets
|
|
21
|
+
SERVICE = "agents_control"
|
|
22
|
+
|
|
23
|
+
def initialize(executor: Executor.new, providers: nil)
|
|
24
|
+
@executor = executor
|
|
25
|
+
@providers = providers || default_providers
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get(key)
|
|
29
|
+
readable.each do |provider|
|
|
30
|
+
value = provider.get(key.to_s)
|
|
31
|
+
return value if value && !value.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set(key, value)
|
|
38
|
+
provider = writable.first
|
|
39
|
+
raise Error, "no secret storage available" unless provider
|
|
40
|
+
|
|
41
|
+
provider.set(key.to_s, value)
|
|
42
|
+
provider
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key)
|
|
46
|
+
writable.each { |provider| provider.delete(key.to_s) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Where a secret lives, and where it will be written — for doctor.
|
|
50
|
+
def source_for(key)
|
|
51
|
+
readable.find { |provider| provider.get(key.to_s) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def target = writable.first
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def readable = @providers.select(&:available?)
|
|
59
|
+
|
|
60
|
+
def writable = readable.select(&:writable?)
|
|
61
|
+
|
|
62
|
+
def default_providers
|
|
63
|
+
[
|
|
64
|
+
Providers::Env.new,
|
|
65
|
+
Providers::Keychain.new(executor: @executor),
|
|
66
|
+
Providers::SecretTool.new(executor: @executor),
|
|
67
|
+
Providers::File.new
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module Providers
|
|
72
|
+
# Environment variables. Read-only — writing into someone else's
|
|
73
|
+
# process isn't possible. Needed for CI, containers, and headless
|
|
74
|
+
# machines with no keyring daemon.
|
|
75
|
+
class Env
|
|
76
|
+
def name = "environment variable"
|
|
77
|
+
def available? = true
|
|
78
|
+
def writable? = false
|
|
79
|
+
|
|
80
|
+
def get(key) = ENV.fetch(variable_for(key), nil)
|
|
81
|
+
|
|
82
|
+
def set(_key, _value) = raise(Error, "cannot write an environment variable")
|
|
83
|
+
|
|
84
|
+
def delete(_key) = nil
|
|
85
|
+
|
|
86
|
+
def variable_for(key) = "AGENTS_CONTROL_#{key.upcase}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# macOS Keychain.
|
|
90
|
+
class Keychain
|
|
91
|
+
def initialize(executor: Executor.new)
|
|
92
|
+
@executor = executor
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def name = "Keychain (macOS)"
|
|
96
|
+
|
|
97
|
+
def available? = !binary.nil?
|
|
98
|
+
|
|
99
|
+
def writable? = true
|
|
100
|
+
|
|
101
|
+
def get(key)
|
|
102
|
+
result = @executor.run(binary, "find-generic-password", "-s", SERVICE, "-a", key, "-w")
|
|
103
|
+
return nil unless result.success?
|
|
104
|
+
|
|
105
|
+
decode(result.stdout.strip)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The command goes over stdin in interactive mode, not argv:
|
|
109
|
+
# `security add-generic-password -w SECRET` would expose the
|
|
110
|
+
# token in `ps`, and the man page calls passing a password as an
|
|
111
|
+
# argument insecure outright.
|
|
112
|
+
#
|
|
113
|
+
# The cost of this is that interactive mode splits the line on
|
|
114
|
+
# whitespace, so a value containing a space or newline would be
|
|
115
|
+
# silently mangled. Better to refuse loudly than to save a
|
|
116
|
+
# truncated token and chase a confusing auth error later.
|
|
117
|
+
def set(key, value)
|
|
118
|
+
unless value.to_s.match?(/\A[\x21-\x7E]+\z/)
|
|
119
|
+
raise Error, "Keychain only accepts printable ASCII with no whitespace"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
command = "add-generic-password -s #{SERVICE} -a #{key} -w #{value} -U\n"
|
|
123
|
+
|
|
124
|
+
@executor.run(binary, "-i", stdin: command).success?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def delete(key)
|
|
128
|
+
@executor.run(binary, "delete-generic-password", "-s", SERVICE, "-a", key).success?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# `security -w` prints the value as-is while it's ASCII, but
|
|
134
|
+
# switches to hex the moment other bytes show up inside it.
|
|
135
|
+
# Telegram tokens are always ASCII, but the store is shared —
|
|
136
|
+
# decode that case too, carefully: only when the result actually
|
|
137
|
+
# looks like packed text, not like a secret made of pure hex digits.
|
|
138
|
+
def decode(value)
|
|
139
|
+
return value unless value.match?(/\A(?:\h\h)+\z/) && value.length.even?
|
|
140
|
+
|
|
141
|
+
bytes = [value].pack("H*").force_encoding(Encoding::UTF_8)
|
|
142
|
+
bytes.valid_encoding? && !bytes.ascii_only? ? bytes : value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def binary
|
|
146
|
+
@binary = Which.find("security") || false if @binary.nil?
|
|
147
|
+
@binary || nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Linux: libsecret on top of gnome-keyring or KWallet.
|
|
152
|
+
class SecretTool
|
|
153
|
+
def initialize(executor: Executor.new)
|
|
154
|
+
@executor = executor
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def name = "libsecret (Linux)"
|
|
158
|
+
|
|
159
|
+
def available? = !binary.nil?
|
|
160
|
+
|
|
161
|
+
def writable? = true
|
|
162
|
+
|
|
163
|
+
def get(key)
|
|
164
|
+
result = @executor.run(binary, "lookup", "service", SERVICE, "account", key)
|
|
165
|
+
result.success? ? result.stdout.chomp : nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# secret-tool reads the secret from stdin on its own — argv stays clean.
|
|
169
|
+
def set(key, value)
|
|
170
|
+
result = @executor.run(
|
|
171
|
+
binary, "store", "--label=#{SERVICE} #{key}",
|
|
172
|
+
"service", SERVICE, "account", key,
|
|
173
|
+
stdin: value
|
|
174
|
+
)
|
|
175
|
+
result.success?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def delete(key)
|
|
179
|
+
@executor.run(binary, "clear", "service", SERVICE, "account", key).success?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def binary
|
|
185
|
+
@binary = Which.find("secret-tool") || false if @binary.nil?
|
|
186
|
+
@binary || nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Last resort: a file with 0600 permissions.
|
|
191
|
+
#
|
|
192
|
+
# Needed where there's no keyring daemon at all — a headless server
|
|
193
|
+
# or a container. Worse than the other options, so we warn about it.
|
|
194
|
+
class File
|
|
195
|
+
def self.path
|
|
196
|
+
base = ENV["XDG_STATE_HOME"] || ::File.expand_path("~/.local/state")
|
|
197
|
+
::File.join(base, "agents_control", "credentials.json")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def initialize(path: self.class.path)
|
|
201
|
+
@path = path
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def name = "file #{@path} (0600)"
|
|
205
|
+
|
|
206
|
+
def available? = true
|
|
207
|
+
|
|
208
|
+
def writable? = true
|
|
209
|
+
|
|
210
|
+
def insecure? = true
|
|
211
|
+
|
|
212
|
+
def get(key) = read[key]
|
|
213
|
+
|
|
214
|
+
def set(key, value)
|
|
215
|
+
data = read.merge(key => value)
|
|
216
|
+
FileUtils.mkdir_p(::File.dirname(@path))
|
|
217
|
+
|
|
218
|
+
# Permissions are set before writing: otherwise there's a window
|
|
219
|
+
# between file creation and chmod where the secret is
|
|
220
|
+
# world-readable.
|
|
221
|
+
::File.open(@path, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600) do |file|
|
|
222
|
+
file.write(JSON.generate(data))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def delete(key)
|
|
229
|
+
data = read
|
|
230
|
+
return false unless data.key?(key)
|
|
231
|
+
|
|
232
|
+
set_all(data.except(key))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def read
|
|
238
|
+
return {} unless ::File.exist?(@path)
|
|
239
|
+
|
|
240
|
+
parsed = JSON.parse(::File.read(@path))
|
|
241
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
242
|
+
rescue JSON::ParserError
|
|
243
|
+
{}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def set_all(data)
|
|
247
|
+
::File.open(@path, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600) do |file|
|
|
248
|
+
file.write(JSON.generate(data))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
# Autostarting the daemon: launchd on macOS, systemd on Linux.
|
|
8
|
+
#
|
|
9
|
+
# Everything here is built around one rule: **absolute paths**. The
|
|
10
|
+
# service starts not from an interactive shell but from a system
|
|
11
|
+
# manager, where PATH is completely different and can't be relied on.
|
|
12
|
+
class Service
|
|
13
|
+
LABEL = "com.agents-control.daemon"
|
|
14
|
+
|
|
15
|
+
def self.for_platform(**args)
|
|
16
|
+
RUBY_PLATFORM.include?("darwin") ? Launchd.new(**args) : Systemd.new(**args)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The interpreter isn't looked up on PATH, it's the one running this
|
|
20
|
+
# code right now: a PATH search could find a version manager's
|
|
21
|
+
# broken shim ahead of the working Ruby. RbConfig.ruby by definition
|
|
22
|
+
# points at the live interpreter.
|
|
23
|
+
def initialize(ruby: nil, script: nil, logger: $stdout)
|
|
24
|
+
@ruby = ruby || RbConfig.ruby
|
|
25
|
+
@script = script || default_script
|
|
26
|
+
@logger = logger
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :ruby, :script
|
|
30
|
+
|
|
31
|
+
def log_path = File.expand_path("~/Library/Logs/agents_control.log")
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Path to the tool's own executable. The gem might not be installed
|
|
36
|
+
# globally — in that case this is the file straight from the repo.
|
|
37
|
+
def default_script
|
|
38
|
+
File.expand_path("../../exe/agents_control", __dir__)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write(path, contents)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
43
|
+
File.write(path, contents)
|
|
44
|
+
path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def log(message) = @logger.puts(message)
|
|
48
|
+
|
|
49
|
+
# macOS.
|
|
50
|
+
class Launchd < Service
|
|
51
|
+
def path = File.expand_path("~/Library/LaunchAgents/#{LABEL}.plist")
|
|
52
|
+
|
|
53
|
+
def install
|
|
54
|
+
write(path, plist)
|
|
55
|
+
reload
|
|
56
|
+
path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def uninstall
|
|
60
|
+
system("launchctl", "unload", path, out: File::NULL, err: File::NULL) if File.exist?(path)
|
|
61
|
+
FileUtils.rm_f(path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def installed? = File.exist?(path)
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def reload
|
|
69
|
+
system("launchctl", "unload", path, out: File::NULL, err: File::NULL)
|
|
70
|
+
system("launchctl", "load", path, out: File::NULL, err: File::NULL)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def plist
|
|
74
|
+
<<~XML
|
|
75
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
76
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
77
|
+
<plist version="1.0">
|
|
78
|
+
<dict>
|
|
79
|
+
<key>Label</key>
|
|
80
|
+
<string>#{LABEL}</string>
|
|
81
|
+
|
|
82
|
+
<!-- Absolute paths are mandatory: launchd's PATH isn't yours. -->
|
|
83
|
+
<key>ProgramArguments</key>
|
|
84
|
+
<array>
|
|
85
|
+
<string>#{ruby}</string>
|
|
86
|
+
<string>#{script}</string>
|
|
87
|
+
<string>daemon</string>
|
|
88
|
+
</array>
|
|
89
|
+
|
|
90
|
+
<key>RunAtLoad</key>
|
|
91
|
+
<true/>
|
|
92
|
+
|
|
93
|
+
<!-- The daemon crashes, launchd brings it back up. Pending
|
|
94
|
+
questions are lost in the process, but nobody's left
|
|
95
|
+
waiting on them anyway: the connection to the agent
|
|
96
|
+
dropped along with the process, and the agent moved on by itself. -->
|
|
97
|
+
<key>KeepAlive</key>
|
|
98
|
+
<true/>
|
|
99
|
+
|
|
100
|
+
<key>StandardOutPath</key>
|
|
101
|
+
<string>#{log_path}</string>
|
|
102
|
+
<key>StandardErrorPath</key>
|
|
103
|
+
<string>#{log_path}</string>
|
|
104
|
+
|
|
105
|
+
<key>EnvironmentVariables</key>
|
|
106
|
+
<dict>
|
|
107
|
+
<key>PATH</key>
|
|
108
|
+
<string>#{safe_path}</string>
|
|
109
|
+
</dict>
|
|
110
|
+
</dict>
|
|
111
|
+
</plist>
|
|
112
|
+
XML
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The same order as in binary lookup: version managers and ARM
|
|
116
|
+
# brew ahead of the system directories.
|
|
117
|
+
def safe_path
|
|
118
|
+
["#{Dir.home}/.asdf/shims", "/opt/homebrew/bin", "/opt/homebrew/sbin",
|
|
119
|
+
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Linux.
|
|
124
|
+
class Systemd < Service
|
|
125
|
+
def path = File.expand_path("~/.config/systemd/user/agents-control.service")
|
|
126
|
+
|
|
127
|
+
def log_path = "journal"
|
|
128
|
+
|
|
129
|
+
def install
|
|
130
|
+
write(path, unit)
|
|
131
|
+
system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
|
|
132
|
+
system("systemctl", "--user", "enable", "--now", "agents-control",
|
|
133
|
+
out: File::NULL, err: File::NULL)
|
|
134
|
+
path
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def uninstall
|
|
138
|
+
system("systemctl", "--user", "disable", "--now", "agents-control",
|
|
139
|
+
out: File::NULL, err: File::NULL)
|
|
140
|
+
FileUtils.rm_f(path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def installed? = File.exist?(path)
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def unit
|
|
148
|
+
<<~UNIT
|
|
149
|
+
[Unit]
|
|
150
|
+
Description=agents_control — control AI agents from Telegram
|
|
151
|
+
After=network-online.target
|
|
152
|
+
|
|
153
|
+
[Service]
|
|
154
|
+
Type=simple
|
|
155
|
+
ExecStart=#{ruby} #{script} daemon
|
|
156
|
+
Restart=always
|
|
157
|
+
RestartSec=5
|
|
158
|
+
|
|
159
|
+
[Install]
|
|
160
|
+
WantedBy=default.target
|
|
161
|
+
UNIT
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# One session — a terminal tab, or an agent with no terminal at all.
|
|
5
|
+
#
|
|
6
|
+
# Deliberately knows nothing about Claude or iTerm2: this is the common
|
|
7
|
+
# denominator the core and Telegram operate on. Anything backend-specific
|
|
8
|
+
# lives in the adapters.
|
|
9
|
+
class Session
|
|
10
|
+
attr_reader :id, :backend, :tty, :title, :cwd, :agent, :agent_pid, :foreground_command
|
|
11
|
+
|
|
12
|
+
def initialize(id:, backend: nil, tty: nil, title: nil, cwd: nil,
|
|
13
|
+
processing: nil, at_shell_prompt: nil,
|
|
14
|
+
agent: nil, agent_pid: nil, foreground_command: nil)
|
|
15
|
+
@id = id
|
|
16
|
+
@backend = backend
|
|
17
|
+
@tty = tty
|
|
18
|
+
@title = title
|
|
19
|
+
@cwd = cwd
|
|
20
|
+
@processing = processing
|
|
21
|
+
@at_shell_prompt = at_shell_prompt
|
|
22
|
+
@agent = agent
|
|
23
|
+
@agent_pid = agent_pid
|
|
24
|
+
@foreground_command = foreground_command
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Terminal is busy working. iTerm2 has `is processing`; tmux has no
|
|
28
|
+
# direct equivalent, so the value can be nil — "unknown", not "no".
|
|
29
|
+
def processing? = @processing == true
|
|
30
|
+
|
|
31
|
+
def at_shell_prompt? = @at_shell_prompt == true
|
|
32
|
+
|
|
33
|
+
# Agent presence is confirmed by the process tree, not the tab title.
|
|
34
|
+
def agent? = !@agent.nil?
|
|
35
|
+
|
|
36
|
+
# A session without a tab: launched from VS Code or another GUI
|
|
37
|
+
# client. Can't be controlled through a terminal, only reached via hooks.
|
|
38
|
+
def terminalless? = @backend.nil? || @tty.nil?
|
|
39
|
+
|
|
40
|
+
# Short name for the Telegram list: the directory name is more
|
|
41
|
+
# informative than a full path or a title that may be stale.
|
|
42
|
+
def label
|
|
43
|
+
return File.basename(cwd) if cwd && !cwd.empty?
|
|
44
|
+
return title if title && !title.empty?
|
|
45
|
+
|
|
46
|
+
id.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with(**attrs)
|
|
50
|
+
Session.new(**to_h.merge(attrs))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
{
|
|
55
|
+
id: id, backend: backend, tty: tty, title: title, cwd: cwd,
|
|
56
|
+
processing: @processing, at_shell_prompt: @at_shell_prompt,
|
|
57
|
+
agent: agent, agent_pid: agent_pid, foreground_command: foreground_command
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ==(other) = other.is_a?(Session) && other.to_h == to_h
|
|
62
|
+
alias eql? ==
|
|
63
|
+
|
|
64
|
+
def hash = to_h.hash
|
|
65
|
+
end
|
|
66
|
+
end
|