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,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
def self.exit_on_failure? = true
|
|
9
|
+
|
|
10
|
+
# With no arguments the console opens: the tool's working state is
|
|
11
|
+
# to live in a tab, not run once and exit.
|
|
12
|
+
default_task :console
|
|
13
|
+
|
|
14
|
+
desc "console", "Interactive console (default)"
|
|
15
|
+
long_desc <<~TEXT
|
|
16
|
+
Brings up the daemon and stays in the tab. Commands inside start
|
|
17
|
+
with a slash, same as the bot's: /sessions, /tabs, /away, /settings, /doctor.
|
|
18
|
+
TEXT
|
|
19
|
+
def console = Console.new(config: config, store: store, secrets: secrets).run
|
|
20
|
+
|
|
21
|
+
desc "sessions", "Show agent sessions"
|
|
22
|
+
long_desc <<~TEXT
|
|
23
|
+
By default prints only sessions with a confirmed live agent.
|
|
24
|
+
Confirmation comes from the process tree, not the tab title: the
|
|
25
|
+
title stays up after the agent exits and lies.
|
|
26
|
+
|
|
27
|
+
With --all, prints every terminal tab.
|
|
28
|
+
TEXT
|
|
29
|
+
option :all, type: :boolean, default: false, aliases: "-a",
|
|
30
|
+
desc: "All tabs, not just agents"
|
|
31
|
+
def sessions
|
|
32
|
+
registry = Registry.new
|
|
33
|
+
list = options[:all] ? registry.sessions : registry.agents
|
|
34
|
+
|
|
35
|
+
if list.empty?
|
|
36
|
+
say(options[:all] ? "No tabs found." : "No live agent sessions.", :yellow)
|
|
37
|
+
say("Available backends: #{backend_names(registry)}", :white)
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
print_table_of(list)
|
|
42
|
+
say("")
|
|
43
|
+
say("Total: #{list.size} · backends: #{backend_names(registry)}", :white)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "setup", "Set up the Telegram bot"
|
|
47
|
+
long_desc <<~TEXT
|
|
48
|
+
Asks for a token from @BotFather, verifies it, and waits for you
|
|
49
|
+
to send the bot /start — to learn your chat_id and add it to the
|
|
50
|
+
allowed list. While that list is empty, the bot answers nobody.
|
|
51
|
+
|
|
52
|
+
The token is entered without echo and saved to the Keychain or
|
|
53
|
+
libsecret. It's deliberately never accepted as a command-line
|
|
54
|
+
argument: it would leak into `ps` and shell history.
|
|
55
|
+
TEXT
|
|
56
|
+
def setup
|
|
57
|
+
token = ask_token
|
|
58
|
+
return if token.nil? || !valid_shape?(token)
|
|
59
|
+
|
|
60
|
+
api = Api.new(token)
|
|
61
|
+
me = verify(api)
|
|
62
|
+
return unless me
|
|
63
|
+
|
|
64
|
+
secrets.set(:telegram_token, token)
|
|
65
|
+
say("Token saved: #{secrets.target.name}", :green)
|
|
66
|
+
publish_commands(api)
|
|
67
|
+
|
|
68
|
+
capture_chat_id(api, me)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc "daemon", "Run the daemon: agent hooks plus Telegram"
|
|
72
|
+
long_desc <<~TEXT
|
|
73
|
+
Listens to Telegram and receives events from agents. Stops with Ctrl-C.
|
|
74
|
+
|
|
75
|
+
While "present" mode is on, agent questions are only mirrored to
|
|
76
|
+
Telegram and stay in the terminal. The /away command in the bot
|
|
77
|
+
switches to interception: then a question waits for a reply from your phone.
|
|
78
|
+
TEXT
|
|
79
|
+
def daemon = Daemon.new(config: config, store: store, secrets: secrets).run
|
|
80
|
+
|
|
81
|
+
desc "hooks SUBCOMMAND", "Connect or disconnect agent hooks (install/uninstall/status)"
|
|
82
|
+
def hooks(subcommand = "status")
|
|
83
|
+
case subcommand
|
|
84
|
+
when "install" then hooks_install
|
|
85
|
+
when "uninstall" then hooks_uninstall
|
|
86
|
+
when "status" then hooks_status
|
|
87
|
+
else say("I don't know the subcommand #{subcommand}. Try install, uninstall, status.", :red)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
desc "stop", "Stop a running daemon"
|
|
92
|
+
long_desc <<~TEXT
|
|
93
|
+
Finds the process holding the hooks port and asks it to exit.
|
|
94
|
+
Useful when an instance was left running somewhere and a new one won't start.
|
|
95
|
+
TEXT
|
|
96
|
+
def stop
|
|
97
|
+
port = config.get("hooks.port", Daemon::DEFAULT_PORT).to_i
|
|
98
|
+
# argv array, not a shell string: no reason to trust a shell to
|
|
99
|
+
# parse a port number pulled from config.
|
|
100
|
+
out, = Open3.capture3("lsof", "-nP", "-iTCP:#{port}", "-sTCP:LISTEN", "-t")
|
|
101
|
+
pids = out.split
|
|
102
|
+
return say("Nothing is running.", :yellow) if pids.empty?
|
|
103
|
+
|
|
104
|
+
pids.each do |pid|
|
|
105
|
+
Process.kill("TERM", pid.to_i)
|
|
106
|
+
say("Stopped #{pid}", :green)
|
|
107
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
108
|
+
say("Couldn't stop #{pid}: #{e.message}", :red)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
desc "doctor", "Check that everything is in place"
|
|
113
|
+
long_desc <<~TEXT
|
|
114
|
+
Checks the environment the daemon will actually get, not the one
|
|
115
|
+
you're sitting in right now: an interactive shell lies about
|
|
116
|
+
versions and paths.
|
|
117
|
+
TEXT
|
|
118
|
+
def doctor
|
|
119
|
+
checks = Doctor.new(config: config, secrets: secrets).run
|
|
120
|
+
|
|
121
|
+
checks.each do |check|
|
|
122
|
+
colour = { ok: :green, warn: :yellow, fail: :red }[check.status]
|
|
123
|
+
say("#{check.icon} #{check.name.ljust(22)} #{check.detail}", colour)
|
|
124
|
+
say(" └ #{check.fix}", :white) if check.fix
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
broken = checks.count(&:failed?)
|
|
128
|
+
say("")
|
|
129
|
+
say(broken.zero? ? "Everything checks out." : "Not okay: #{broken}",
|
|
130
|
+
broken.zero? ? :green : :red)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
desc "service SUBCOMMAND", "Autostart the daemon (install/uninstall/status)"
|
|
134
|
+
def service(subcommand = "status")
|
|
135
|
+
unit = Service.for_platform
|
|
136
|
+
|
|
137
|
+
case subcommand
|
|
138
|
+
when "install"
|
|
139
|
+
say("Installed #{unit.install}", :green)
|
|
140
|
+
say("Ruby: #{unit.ruby}", :white)
|
|
141
|
+
say("Log: #{unit.log_path}", :white)
|
|
142
|
+
when "uninstall" then unit.uninstall && say("Autostart removed", :green)
|
|
143
|
+
else say(unit.installed? ? "Autostart configured: #{unit.path}" : "Autostart not configured")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
desc "version", "Version"
|
|
148
|
+
def version = say(AgentsControl::VERSION)
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
Api = Channels::Telegram::Api
|
|
153
|
+
Router = Channels::Telegram::Router
|
|
154
|
+
Bot = Channels::Telegram::Bot
|
|
155
|
+
|
|
156
|
+
def config = @config ||= Config.load
|
|
157
|
+
|
|
158
|
+
def store = @store ||= Store.new
|
|
159
|
+
|
|
160
|
+
def secrets = @secrets ||= Secrets.new
|
|
161
|
+
|
|
162
|
+
def agents = [Agents::ClaudeCode.new]
|
|
163
|
+
|
|
164
|
+
# Connecting hooks as a separate command usually isn't necessary —
|
|
165
|
+
# the daemon does it itself on start. The command exists for
|
|
166
|
+
# inspecting and fixing things by hand.
|
|
167
|
+
def hooks_install
|
|
168
|
+
url = "http://127.0.0.1:#{config.get('hooks.port', Daemon::DEFAULT_PORT)}"
|
|
169
|
+
secret = secrets.get(:hook_secret) || SecureRandom.hex(16)
|
|
170
|
+
secrets.set(:hook_secret, secret)
|
|
171
|
+
|
|
172
|
+
agents.each do |agent|
|
|
173
|
+
agent.install!(url, secret: secret)
|
|
174
|
+
say("#{agent.key}: connected on #{url}", :green)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
say("#{agent.key}: #{e.message}", :red)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def hooks_uninstall
|
|
181
|
+
agents.each do |agent|
|
|
182
|
+
agent.uninstall!
|
|
183
|
+
say("#{agent.key}: disconnected", :green)
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
say("#{agent.key}: #{e.message}", :red)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def hooks_status
|
|
190
|
+
agents.each do |agent|
|
|
191
|
+
state = agent.installed? ? "connected" : "not connected"
|
|
192
|
+
say("#{agent.key}: hooks #{state}")
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
say("#{agent.key}: #{e.message}", :red)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The token is read only from the terminal or stdin — deliberately
|
|
199
|
+
# never accepted as a command-line flag, or it would leak into `ps`
|
|
200
|
+
# and shell history.
|
|
201
|
+
def ask_token
|
|
202
|
+
token = $stdin.tty? ? ask_token_interactively : $stdin.gets.to_s.strip
|
|
203
|
+
|
|
204
|
+
return nil if token.empty? && warn_empty_token
|
|
205
|
+
|
|
206
|
+
token
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ask_token_interactively
|
|
210
|
+
$stderr.print("Token from @BotFather: ")
|
|
211
|
+
# Different devices fail differently: ENOTTY, ENODEV, ENXIO.
|
|
212
|
+
# Catching the whole class of system errors, not individual codes.
|
|
213
|
+
$stdin.noecho(&:gets).to_s.strip
|
|
214
|
+
rescue SystemCallError, IOError
|
|
215
|
+
""
|
|
216
|
+
ensure
|
|
217
|
+
$stderr.puts
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def warn_empty_token
|
|
221
|
+
say("No token received.", :yellow)
|
|
222
|
+
say("Enter it in the terminal, pipe it via stdin, or set " \
|
|
223
|
+
"AGENTS_CONTROL_TELEGRAM_TOKEN.", :white)
|
|
224
|
+
true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A token from BotFather looks like `<digits>:<letters-digits-dashes>`.
|
|
228
|
+
# The shape is checked before touching the network: unprintable
|
|
229
|
+
# characters would land straight in the URL and fail the request
|
|
230
|
+
# with a cryptic address-parsing error.
|
|
231
|
+
TOKEN_SHAPE = /\A\d+:[A-Za-z0-9_-]{30,}\z/
|
|
232
|
+
|
|
233
|
+
def verify(api)
|
|
234
|
+
me = api.get_me
|
|
235
|
+
say("Bot: @#{me['username']}", :green)
|
|
236
|
+
me
|
|
237
|
+
rescue Channels::Telegram::Api::Error => e
|
|
238
|
+
say("Token was rejected: #{e.message}", :red)
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Without this, the bot's command menu only appeared after the first
|
|
243
|
+
# daemon run (agents_control / agents_control daemon) — right after
|
|
244
|
+
# setup the bot would already answer, but the command list wouldn't
|
|
245
|
+
# be in Telegram's UI yet.
|
|
246
|
+
def publish_commands(api)
|
|
247
|
+
api.set_my_commands(Channels::Telegram::Router::COMMANDS)
|
|
248
|
+
rescue Channels::Telegram::Api::Error
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def valid_shape?(token)
|
|
253
|
+
return true if token.match?(TOKEN_SHAPE)
|
|
254
|
+
|
|
255
|
+
say("This doesn't look like a BotFather token.", :red)
|
|
256
|
+
say("Expecting something like 123456789:AA... — copy the whole string.", :white)
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# chat_id is learned without asking: the user writes to the bot, and
|
|
261
|
+
# we read who from. This rules out a typo in a long number.
|
|
262
|
+
def capture_chat_id(api, me)
|
|
263
|
+
say("")
|
|
264
|
+
say("Now message the bot @#{me['username']} with /start", :yellow)
|
|
265
|
+
say("Waiting up to 120 seconds…")
|
|
266
|
+
|
|
267
|
+
chat = wait_for_message(api)
|
|
268
|
+
return say("Timed out. Run setup again.", :red) unless chat
|
|
269
|
+
|
|
270
|
+
allow(chat)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def wait_for_message(api, seconds: 120)
|
|
274
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
275
|
+
offset = nil
|
|
276
|
+
|
|
277
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
278
|
+
updates = api.get_updates(offset: offset, timeout: 20)
|
|
279
|
+
found = updates.find { |update| update.dig("message", "chat", "id") }
|
|
280
|
+
|
|
281
|
+
return found["message"]["chat"] if found
|
|
282
|
+
|
|
283
|
+
offset = updates.last["update_id"] + 1 unless updates.empty?
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
nil
|
|
287
|
+
rescue Channels::Telegram::Api::Error => e
|
|
288
|
+
say("Error while waiting: #{e.message}", :red)
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def allow(chat)
|
|
293
|
+
allowed = (config.get("telegram.allowed_chat_ids", []) + [chat["id"]]).uniq
|
|
294
|
+
config.set("telegram.allowed_chat_ids", allowed).save
|
|
295
|
+
|
|
296
|
+
name = [chat["first_name"], chat["username"] && "@#{chat['username']}"].compact.join(" ")
|
|
297
|
+
say("Done. Chat allowed: #{chat['id']} #{name}".strip, :green)
|
|
298
|
+
say("Run: agents_control bot")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def backend_names(registry)
|
|
302
|
+
names = registry.available_backends.map(&:name)
|
|
303
|
+
names.empty? ? "none" : names.join(", ")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def print_table_of(list)
|
|
307
|
+
rows = list.map { |session| row_for(session) }
|
|
308
|
+
widths = column_widths(rows)
|
|
309
|
+
|
|
310
|
+
rows.each do |cells|
|
|
311
|
+
say(cells.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def row_for(session)
|
|
316
|
+
[
|
|
317
|
+
state_marker(session),
|
|
318
|
+
session.agent ? session.agent.to_s : (session.foreground_command || "—"),
|
|
319
|
+
session.label,
|
|
320
|
+
location(session),
|
|
321
|
+
session.id
|
|
322
|
+
]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# State is encoded as an icon so the list reads at a glance, not
|
|
326
|
+
# column by column.
|
|
327
|
+
def state_marker(session)
|
|
328
|
+
return "⏳" if session.processing?
|
|
329
|
+
return "🖥" if session.terminalless?
|
|
330
|
+
return "▸" if session.at_shell_prompt?
|
|
331
|
+
|
|
332
|
+
"·"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# For a tabless session, printing a tty is meaningless — there isn't one.
|
|
336
|
+
def location(session)
|
|
337
|
+
return "vscode" if session.terminalless?
|
|
338
|
+
|
|
339
|
+
session.tty.to_s.sub(%r{\A/dev/}, "")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def column_widths(rows)
|
|
343
|
+
count = rows.map(&:size).max.to_i
|
|
344
|
+
(0...count).map { |i| rows.map { |cells| cells[i].to_s.length }.max }
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
# YAML settings at the XDG path.
|
|
8
|
+
#
|
|
9
|
+
# Only non-secret data lives here. The bot token lives in the Keychain
|
|
10
|
+
# or libsecret and never lands in this file — the tool is meant to be
|
|
11
|
+
# published openly, and the config has to be safe to show anyone.
|
|
12
|
+
class Config
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"telegram" => {
|
|
15
|
+
# While the list is empty, the bot answers nobody. Filled in by
|
|
16
|
+
# the setup wizard. Without this filter, anyone who found the bot
|
|
17
|
+
# could approve command execution on this machine.
|
|
18
|
+
"allowed_chat_ids" => [],
|
|
19
|
+
"poll_timeout" => 30
|
|
20
|
+
},
|
|
21
|
+
"answers" => {
|
|
22
|
+
# Answering "continue" on the user's behalf is safe.
|
|
23
|
+
"auto_continue" => true,
|
|
24
|
+
# Granting the agent a tool on the user's behalf is not.
|
|
25
|
+
# Deliberately two separate settings: merged into one, they'd
|
|
26
|
+
# produce an agent that approves itself everything while nobody
|
|
27
|
+
# is watching.
|
|
28
|
+
"auto_approve_permissions" => false,
|
|
29
|
+
# Whether to intercept agent questions at all. While a human is
|
|
30
|
+
# at the keyboard this only gets in the way: they'll answer in
|
|
31
|
+
# the terminal faster, and a busy hook keeps the dialog from
|
|
32
|
+
# ever reaching the screen. Turned on with the /away command.
|
|
33
|
+
"away" => false,
|
|
34
|
+
# Whether to also mirror questions to Telegram when interception
|
|
35
|
+
# is off.
|
|
36
|
+
"notify_when_present" => true,
|
|
37
|
+
# How long to wait for a Telegram reply before declining. Claude
|
|
38
|
+
# Code holds the hook open longer than the documented 600
|
|
39
|
+
# seconds, so this upper bound is ours, not its.
|
|
40
|
+
"reply_timeout" => 900,
|
|
41
|
+
# Automatically type a continuation once a session has hit a
|
|
42
|
+
# rate limit and the limit has reset. Exactly as safe as
|
|
43
|
+
# auto_continue — grants the agent no new permissions, just
|
|
44
|
+
# clears idle time nobody got around to clearing by hand.
|
|
45
|
+
"auto_resume_after_limit" => true,
|
|
46
|
+
"resume_message" => "Continue where you left off — the previous attempt was rate limited.",
|
|
47
|
+
# Never auto-approved, no matter what the settings above say.
|
|
48
|
+
"never_auto_approve" => [
|
|
49
|
+
"rm -rf",
|
|
50
|
+
"git push --force",
|
|
51
|
+
"curl | sh",
|
|
52
|
+
"curl | bash",
|
|
53
|
+
".env"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"anchors" => {
|
|
57
|
+
"enabled" => false,
|
|
58
|
+
"mode" => "headless",
|
|
59
|
+
# The five-hour window is shared across the account, but weekly
|
|
60
|
+
# limits are tracked per model family — so anchoring with a
|
|
61
|
+
# cheap model is the better deal.
|
|
62
|
+
"model" => "haiku",
|
|
63
|
+
"schedule" => ["07:00", "12:00", "17:00"],
|
|
64
|
+
"days" => %w[mon tue wed thu fri],
|
|
65
|
+
"skip_if_window_active" => true
|
|
66
|
+
},
|
|
67
|
+
"terminal" => {
|
|
68
|
+
# Empty means auto-detect. Otherwise iterm2 / tmux / none.
|
|
69
|
+
"backend" => nil,
|
|
70
|
+
"context_lines" => 80,
|
|
71
|
+
# Watch for a session's local CLI menus (model switch, folder
|
|
72
|
+
# trust) on screen and forward them to Telegram. Hooks can't see
|
|
73
|
+
# these — they're the CLI's own response to a human command, not
|
|
74
|
+
# a decision made by the agent.
|
|
75
|
+
"watch_menus" => true,
|
|
76
|
+
"menu_poll_interval" => 20,
|
|
77
|
+
"rate_limit_poll_interval" => 60
|
|
78
|
+
}
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def self.path
|
|
82
|
+
base = ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
|
|
83
|
+
File.join(base, "agents_control", "config.yml")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.load(path = self.path)
|
|
87
|
+
new(read(path), path: path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.read(path)
|
|
91
|
+
return {} unless File.exist?(path)
|
|
92
|
+
|
|
93
|
+
YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
94
|
+
rescue Psych::SyntaxError
|
|
95
|
+
# A broken config must not keep the daemon from starting: fall
|
|
96
|
+
# back to defaults and flag it in doctor.
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
attr_reader :path
|
|
101
|
+
|
|
102
|
+
def initialize(data = {}, path: self.class.path)
|
|
103
|
+
@data = deep_merge(DEFAULTS, stringify(data))
|
|
104
|
+
@path = path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Reads via a dotted path: config.get("anchors.model").
|
|
108
|
+
def get(key, default = nil)
|
|
109
|
+
key.to_s.split(".").reduce(@data) do |node, part|
|
|
110
|
+
return default unless node.is_a?(Hash) && node.key?(part)
|
|
111
|
+
|
|
112
|
+
node[part]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def set(key, value)
|
|
117
|
+
parts = key.to_s.split(".")
|
|
118
|
+
leaf = parts[0..-2].reduce(@data) { |node, part| node[part] ||= {} }
|
|
119
|
+
leaf[parts.last] = value
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Written through a temp file, chmod'd before the rename: the file
|
|
124
|
+
# may contain the allowed-chats list, and it must never be
|
|
125
|
+
# world-readable even for the brief moment between creation and the
|
|
126
|
+
# permission change landing on the final path.
|
|
127
|
+
def save
|
|
128
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
129
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
130
|
+
|
|
131
|
+
File.write(temporary, YAML.dump(@data))
|
|
132
|
+
File.chmod(0o600, temporary)
|
|
133
|
+
File.rename(temporary, path)
|
|
134
|
+
self
|
|
135
|
+
ensure
|
|
136
|
+
FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def to_h = @data
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Hash#merge only copies the top level: nested hashes stay the same
|
|
144
|
+
# objects as in DEFAULTS. Without a deep copy, the first `set` would
|
|
145
|
+
# edit the defaults for every other instance at once — in the daemon
|
|
146
|
+
# that would mean the allowed-chats list leaking between config reloads.
|
|
147
|
+
def deep_merge(base, other)
|
|
148
|
+
result = deep_dup(base)
|
|
149
|
+
|
|
150
|
+
other.each do |key, value|
|
|
151
|
+
result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
|
|
152
|
+
deep_merge(result[key], value)
|
|
153
|
+
else
|
|
154
|
+
deep_dup(value)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def deep_dup(object)
|
|
162
|
+
case object
|
|
163
|
+
when Hash then object.to_h { |key, value| [key, deep_dup(value)] }
|
|
164
|
+
when Array then object.map { |item| deep_dup(item) }
|
|
165
|
+
when String then object.dup
|
|
166
|
+
else object
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def stringify(object)
|
|
171
|
+
case object
|
|
172
|
+
when Hash then object.to_h { |key, value| [key.to_s, stringify(value)] }
|
|
173
|
+
when Array then object.map { |item| stringify(item) }
|
|
174
|
+
else object
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|