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,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
module Channels
|
|
5
|
+
module Telegram
|
|
6
|
+
# Parses incoming updates and runs commands.
|
|
7
|
+
#
|
|
8
|
+
# The sender check runs first, before anything else is parsed.
|
|
9
|
+
# An empty allowed-chats list means "answer nobody".
|
|
10
|
+
class Router
|
|
11
|
+
MAX_MESSAGE = Chunker::MAX_MESSAGE
|
|
12
|
+
|
|
13
|
+
# Commands in these tabs go to a remote server, not the laptop —
|
|
14
|
+
# they get a separate confirmation.
|
|
15
|
+
REMOTE_COMMANDS = %w[ssh mosh].freeze
|
|
16
|
+
|
|
17
|
+
# Shared list for the Telegram menu and for /help: a menu that's
|
|
18
|
+
# drifted from reality is worse than no menu. The third element
|
|
19
|
+
# is the argument syntax, needed only in /help; setMyCommands
|
|
20
|
+
# doesn't show the argument.
|
|
21
|
+
COMMANDS = [
|
|
22
|
+
["agents", "sessions with a live agent"],
|
|
23
|
+
["tabs", "all terminal tabs"],
|
|
24
|
+
["status", "what's happening right now"],
|
|
25
|
+
["away", "intercept agent questions"],
|
|
26
|
+
["settings", "settings"],
|
|
27
|
+
["context", "the agent's recent messages", "N"],
|
|
28
|
+
["screen", "show a tab's screen", "N"],
|
|
29
|
+
["focus", "switch to a tab", "N"],
|
|
30
|
+
["run", "run a command in a tab", "N command"],
|
|
31
|
+
["new", "new tab", "[directory]"],
|
|
32
|
+
["help", "this help text"]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
HELP = (["Commands:", ""] +
|
|
36
|
+
COMMANDS.map { |name, text, hint| "/#{name}#{hint ? " #{hint}" : ''} — #{text}" }).join("\n")
|
|
37
|
+
|
|
38
|
+
def initialize(api:, registry:, store:, config:, keyboards: nil, pending: nil)
|
|
39
|
+
@api = api
|
|
40
|
+
@registry = registry
|
|
41
|
+
@store = store
|
|
42
|
+
@config = config
|
|
43
|
+
@keyboards = keyboards || Keyboards.new(store: store)
|
|
44
|
+
@pending = pending
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle(update)
|
|
48
|
+
if (callback = update["callback_query"])
|
|
49
|
+
handle_callback(callback)
|
|
50
|
+
elsif (message = update["message"])
|
|
51
|
+
handle_message(message)
|
|
52
|
+
end
|
|
53
|
+
rescue Api::Unavailable
|
|
54
|
+
# The offset only advances after successful handling — the update will come back.
|
|
55
|
+
raise
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn("agents_control: #{@api.redact(e.message)}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def allowed?(chat_id)
|
|
63
|
+
allowed = @config.get("telegram.allowed_chat_ids", [])
|
|
64
|
+
|
|
65
|
+
allowed.map(&:to_s).include?(chat_id.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_message(message)
|
|
69
|
+
chat_id = message.dig("chat", "id")
|
|
70
|
+
return unless allowed?(chat_id)
|
|
71
|
+
|
|
72
|
+
text = message["text"].to_s.strip
|
|
73
|
+
|
|
74
|
+
replied = message["reply_to_message"]
|
|
75
|
+
return answer_by_reply(chat_id, replied, text) if replied && !text.empty?
|
|
76
|
+
|
|
77
|
+
return compose_answer(chat_id, text) if composing?(chat_id) && !text.start_with?("/")
|
|
78
|
+
|
|
79
|
+
command, argument = text.split(/\s+/, 2)
|
|
80
|
+
|
|
81
|
+
dispatch(chat_id, command.to_s.sub(/@.*\z/, ""), argument.to_s)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# If the agent is still waiting, the reply goes straight into the
|
|
85
|
+
# open hook — this works even for terminalless sessions.
|
|
86
|
+
# Otherwise the text is typed into the tab, found via target.
|
|
87
|
+
def answer_by_reply(chat_id, replied, text)
|
|
88
|
+
target = @store.get("reply:#{chat_id}:#{replied['message_id']}")
|
|
89
|
+
return say(chat_id, "I don't remember which session that message was about.") unless target
|
|
90
|
+
|
|
91
|
+
question = target["question_id"]
|
|
92
|
+
return deliver(chat_id, question, Reply.text(text)) if question && @pending&.find(question)
|
|
93
|
+
|
|
94
|
+
type_into_session(chat_id, target, text)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# First, the exact session by session_id — unambiguous even when
|
|
98
|
+
# several tabs share one cwd. Matching by cwd is the fallback for
|
|
99
|
+
# when that tab has already closed.
|
|
100
|
+
def type_into_session(chat_id, target, text)
|
|
101
|
+
exact = @registry.refresh.find(target["session_id"])
|
|
102
|
+
return execute(chat_id, exact, text) if exact&.agent? && !exact.terminalless?
|
|
103
|
+
|
|
104
|
+
matching = @registry.agents.select { |s| s.cwd == target["cwd"] && !s.terminalless? }
|
|
105
|
+
|
|
106
|
+
case matching.size
|
|
107
|
+
when 0 then say(chat_id, "#{target['label']} isn't waiting anymore, and I couldn't find the tab.")
|
|
108
|
+
when 1 then execute(chat_id, matching.first, text)
|
|
109
|
+
else say(chat_id, "#{target['label']} has several tabs — use /run NUMBER to pick one.")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def composing_key(chat_id) = "composing:#{chat_id}"
|
|
114
|
+
|
|
115
|
+
def composing?(chat_id) = !@store.get(composing_key(chat_id)).nil?
|
|
116
|
+
|
|
117
|
+
def compose_answer(chat_id, text)
|
|
118
|
+
question_id = @store.take(composing_key(chat_id))
|
|
119
|
+
return say(chat_id, "That question isn't current anymore.") unless question_id
|
|
120
|
+
|
|
121
|
+
deliver(chat_id, question_id, Reply.text(text))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deliver(chat_id, question_id, reply)
|
|
125
|
+
return say(chat_id, "There's nobody to deliver this reply to.") unless @pending
|
|
126
|
+
|
|
127
|
+
if @pending.answer(question_id, reply)
|
|
128
|
+
say(chat_id, "Sent to the agent.")
|
|
129
|
+
else
|
|
130
|
+
say(chat_id, "The agent isn't waiting anymore — the question timed out.")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def dispatch(chat_id, command, argument)
|
|
135
|
+
case command
|
|
136
|
+
when "/start", "/help" then say(chat_id, HELP, markup: @keyboards.main_menu)
|
|
137
|
+
when "/agents", "" then agents_list(chat_id)
|
|
138
|
+
when "/tabs" then tabs_list(chat_id)
|
|
139
|
+
when "/screen" then screen(chat_id, argument)
|
|
140
|
+
when "/focus" then focus(chat_id, argument)
|
|
141
|
+
when "/run" then run(chat_id, argument)
|
|
142
|
+
when "/new" then create_tab(chat_id, argument)
|
|
143
|
+
when "/away" then toggle_away(chat_id, argument)
|
|
144
|
+
when "/settings" then show_settings(chat_id)
|
|
145
|
+
when "/context" then context(chat_id, argument)
|
|
146
|
+
when "/status" then status(chat_id)
|
|
147
|
+
else say(chat_id, "I don't know that command.\n\n#{HELP}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A shared refresh for both lists — otherwise the same session's
|
|
152
|
+
# number could differ between "All tabs" and "Agents".
|
|
153
|
+
def agents_list(chat_id)
|
|
154
|
+
universe = @registry.refresh.sessions
|
|
155
|
+
list(chat_id, universe.select(&:agent?), "🤖 Agent sessions", universe: universe)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def tabs_list(chat_id)
|
|
159
|
+
universe = @registry.refresh.sessions
|
|
160
|
+
list(chat_id, universe, "🖥 All tabs", universe: universe)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def list(chat_id, sessions, title, universe: sessions)
|
|
164
|
+
if sessions.empty?
|
|
165
|
+
return say(chat_id, "#{title}\n\nEmpty.", markup: @keyboards.list_actions)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
say(chat_id, @keyboards.render_list(sessions, universe: universe, chat_id: chat_id, title: title),
|
|
169
|
+
markup: @keyboards.list_actions)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def screen(chat_id, argument)
|
|
173
|
+
with_session(chat_id, argument) { |session| show_screen(chat_id, session) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def focus(chat_id, argument)
|
|
177
|
+
with_session(chat_id, argument) do |session|
|
|
178
|
+
ok = @registry.backend_for(session).focus(session.id)
|
|
179
|
+
say(chat_id, ok ? "Switched to #{session.label}." : "Couldn't switch.")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def run(chat_id, argument)
|
|
184
|
+
number, command = argument.split(/\s+/, 2)
|
|
185
|
+
|
|
186
|
+
return say(chat_id, "Usage: /run NUMBER command") if command.to_s.empty?
|
|
187
|
+
|
|
188
|
+
with_session(chat_id, number) do |session|
|
|
189
|
+
next say(chat_id, "This session has no terminal — nothing to run there.") if session.terminalless?
|
|
190
|
+
|
|
191
|
+
remote?(session) ? confirm_remote(chat_id, session, command) : execute(chat_id, session, command)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A tab's title isn't trustworthy — the foreground process is
|
|
196
|
+
# read from the process tree, same as when looking for an agent.
|
|
197
|
+
def remote?(session)
|
|
198
|
+
REMOTE_COMMANDS.include?(session.foreground_command.to_s)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def confirm_remote(chat_id, session, command)
|
|
202
|
+
key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command },
|
|
203
|
+
ttl: 300)
|
|
204
|
+
|
|
205
|
+
markup = { inline_keyboard: [[
|
|
206
|
+
{ text: "⚠️ Run on #{session.label}", callback_data: key },
|
|
207
|
+
{ text: "cancel", callback_data: @store.put({ "action" => "cancel" }, ttl: 300) }
|
|
208
|
+
]] }
|
|
209
|
+
|
|
210
|
+
say(chat_id, "Tab #{session.label} is busy with #{session.foreground_command}.\n" \
|
|
211
|
+
"The command will go to the remote machine:\n\n`#{command}`",
|
|
212
|
+
markup: markup)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Enter is sent as a separate call, not tacked onto the same
|
|
216
|
+
# input: a merged call can fail to send multi-line text at all.
|
|
217
|
+
TYPING_PAUSE = 0.4
|
|
218
|
+
|
|
219
|
+
def execute(chat_id, session, command)
|
|
220
|
+
backend = @registry.backend_for(session)
|
|
221
|
+
|
|
222
|
+
ok = backend.send_text(session.id, command, newline: false) &&
|
|
223
|
+
sleep(TYPING_PAUSE).then { backend.send_text(session.id, "", newline: true) }
|
|
224
|
+
|
|
225
|
+
say(chat_id, ok ? "Sent to #{session.label}." : "Couldn't send.")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def create_tab(chat_id, directory)
|
|
229
|
+
backend = @registry.available_backends.first
|
|
230
|
+
return say(chat_id, "No terminal available.") unless backend
|
|
231
|
+
|
|
232
|
+
id = backend.create_tab(cwd: directory.empty? ? nil : directory)
|
|
233
|
+
say(chat_id, id ? "Created tab #{id}." : "Couldn't create a tab.")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Interception is turned on explicitly: while a human is at the
|
|
237
|
+
# keyboard, they'll answer in the terminal faster themselves, and
|
|
238
|
+
# a busy hook keeps the dialog from ever reaching the screen at all.
|
|
239
|
+
def toggle_away(chat_id, argument)
|
|
240
|
+
value = case argument.strip.downcase
|
|
241
|
+
when "on", "yes" then true
|
|
242
|
+
when "off", "no" then false
|
|
243
|
+
else !@config.get("answers.away", false)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
@config.set("answers.away", value).save
|
|
247
|
+
|
|
248
|
+
say(chat_id, value ? "🚶 Away. Agent questions now come here." :
|
|
249
|
+
"🪑 Present. Questions stay in the terminal.")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def settings_menu
|
|
253
|
+
@settings_menu ||= SettingsMenu.new(store: @store, config: @config)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def show_settings(chat_id)
|
|
257
|
+
say(chat_id, settings_menu.text, markup: settings_menu.markup)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def transcript_root
|
|
261
|
+
@config.get("terminal.transcript_root", Transcript::PROJECTS)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def context(chat_id, argument)
|
|
265
|
+
with_session(chat_id, argument) do |session|
|
|
266
|
+
transcript = Transcript.for_cwd(session.cwd, root: transcript_root)
|
|
267
|
+
|
|
268
|
+
unless transcript.exists?
|
|
269
|
+
next say(chat_id, "No transcript found — the agent may not have run here.")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
sent = say_chunked(chat_id, "📄 #{session.label}\n\n", transcript.render)
|
|
273
|
+
sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Not necessarily an immediate reply, but whenever convenient — TTL 30 days, not a day.
|
|
278
|
+
REPLY_TARGET_TTL = 30 * 86_400
|
|
279
|
+
|
|
280
|
+
def remember_reply(chat_id, sent, session)
|
|
281
|
+
id = sent.is_a?(Hash) ? sent["message_id"] : nil
|
|
282
|
+
return unless id
|
|
283
|
+
|
|
284
|
+
@store.put({ "session_id" => session.id, "cwd" => session.cwd, "label" => session.label },
|
|
285
|
+
ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def status(chat_id)
|
|
289
|
+
sessions = @registry.refresh.sessions
|
|
290
|
+
backends = @registry.available_backends.map(&:name).join(", ")
|
|
291
|
+
|
|
292
|
+
say(chat_id, "Tabs: #{sessions.size}\nAgents: #{sessions.count(&:agent?)}\n" \
|
|
293
|
+
"Backends: #{backends.empty? ? 'none' : backends}\n" \
|
|
294
|
+
"Mode: #{@config.get('answers.away', false) ? '🚶 away' : '🪑 present'}\n" \
|
|
295
|
+
"Waiting for a reply: #{@pending ? @pending.size : 0}")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def handle_callback(callback)
|
|
299
|
+
chat_id = callback.dig("message", "chat", "id")
|
|
300
|
+
return unless allowed?(chat_id)
|
|
301
|
+
|
|
302
|
+
# The Bot API gives ten seconds to answer a button press.
|
|
303
|
+
@api.answer_callback_query(callback["id"])
|
|
304
|
+
|
|
305
|
+
# take — the key is one-shot, redelivery won't run the action twice.
|
|
306
|
+
payload = @store.take(callback["data"].to_s)
|
|
307
|
+
return say(chat_id, "This button expired — pull up the list again.") if payload.nil?
|
|
308
|
+
|
|
309
|
+
perform(chat_id, payload)
|
|
310
|
+
rescue Terminals::Unsupported => e
|
|
311
|
+
say(chat_id, e.message)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def perform(chat_id, payload)
|
|
315
|
+
case payload["action"]
|
|
316
|
+
when "list_agents" then agents_list(chat_id)
|
|
317
|
+
when "list_tabs" then tabs_list(chat_id)
|
|
318
|
+
when "status" then status(chat_id)
|
|
319
|
+
when "show_settings" then show_settings(chat_id)
|
|
320
|
+
when "cancel" then say(chat_id, "Cancelled.")
|
|
321
|
+
when "answer" then answer_question(chat_id, payload)
|
|
322
|
+
when "compose" then start_compose(chat_id, payload)
|
|
323
|
+
when "transcript" then show_transcript(chat_id, payload)
|
|
324
|
+
when "setting" then change_setting(chat_id, payload)
|
|
325
|
+
when "menu_choice" then choose_menu_option(chat_id, payload)
|
|
326
|
+
when "ask_question_choice" then answer_ask_user_question(chat_id, payload)
|
|
327
|
+
else act_on_session(chat_id, payload)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def answer_question(chat_id, payload)
|
|
332
|
+
spec = payload["reply"] || {}
|
|
333
|
+
reply = case spec["kind"]
|
|
334
|
+
when "allow" then Reply.allow(remember: spec["remember"])
|
|
335
|
+
when "deny" then Reply.deny
|
|
336
|
+
else Reply.text(spec["text"].to_s)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
deliver(chat_id, payload["question_id"], reply)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def start_compose(chat_id, payload)
|
|
343
|
+
@store.put(payload["question_id"], ttl: 600, key: composing_key(chat_id))
|
|
344
|
+
say(chat_id, "Write your reply as the next message.")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def change_setting(chat_id, payload)
|
|
348
|
+
changed = settings_menu.apply(payload)
|
|
349
|
+
return say(chat_id, "That setting no longer exists.") unless changed
|
|
350
|
+
|
|
351
|
+
say(chat_id, "#{settings_menu.text}\n\nChanged — #{changed}",
|
|
352
|
+
markup: settings_menu.markup)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Types the option number and Enter right into the pane — the
|
|
356
|
+
# way a human would from the keyboard. There's no structured
|
|
357
|
+
# reply here, unlike with hooks.
|
|
358
|
+
def choose_menu_option(chat_id, payload)
|
|
359
|
+
session = @registry.refresh.find(payload["session_id"])
|
|
360
|
+
return say(chat_id, "That session is already closed.") unless session
|
|
361
|
+
|
|
362
|
+
ok = @registry.backend_for(session).send_text(session.id, payload["choice"].to_s)
|
|
363
|
+
say(chat_id, ok ? "Chose \"#{payload['choice']}\" in #{session.label}." : "Couldn't send the choice.")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Same mechanism as choose_menu_option: types the option number
|
|
367
|
+
# straight into the pane. AskUserQuestion's answer never flows
|
|
368
|
+
# through the hook — it's always been resolved by hand at the
|
|
369
|
+
# terminal, and this just sends the same keystroke a human would.
|
|
370
|
+
def answer_ask_user_question(chat_id, payload)
|
|
371
|
+
session = @registry.refresh.find(payload["session_id"])
|
|
372
|
+
return say(chat_id, "That session is already closed.") unless session
|
|
373
|
+
|
|
374
|
+
ok = @registry.backend_for(session).send_text(session.id, payload["choice"].to_s)
|
|
375
|
+
say(chat_id, ok ? "Sent to #{session.label}." : "Couldn't send the choice.")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def show_transcript(chat_id, payload)
|
|
379
|
+
transcript = Transcript.new(payload["path"])
|
|
380
|
+
return say(chat_id, "No transcript available.") unless transcript.exists?
|
|
381
|
+
|
|
382
|
+
say_chunked(chat_id, "📄 #{payload['label']}\n\n", transcript.render)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def act_on_session(chat_id, payload)
|
|
386
|
+
session = @registry.refresh.find(payload["session_id"])
|
|
387
|
+
return say(chat_id, "That session is already closed.") unless session
|
|
388
|
+
|
|
389
|
+
case payload["action"]
|
|
390
|
+
when "focus" then focus_session(chat_id, session)
|
|
391
|
+
when "screen" then show_screen(chat_id, session)
|
|
392
|
+
when "run" then execute(chat_id, session, payload["text"])
|
|
393
|
+
when "close_confirm" then say(chat_id, "Close #{session.label}?",
|
|
394
|
+
markup: @keyboards.confirm("close", session))
|
|
395
|
+
when "close" then close_session(chat_id, session)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def focus_session(chat_id, session)
|
|
400
|
+
@registry.backend_for(session).focus(session.id)
|
|
401
|
+
say(chat_id, "Switched to #{session.label}.")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The screen is the first choice, not a fallback: it shows what's
|
|
405
|
+
# happening right now (a build, a spinner, a local CLI menu), not
|
|
406
|
+
# just finished lines from the transcript. The transcript stays
|
|
407
|
+
# the fallback for terminalless sessions and for a freshly
|
|
408
|
+
# created tab's blank screen.
|
|
409
|
+
def show_screen(chat_id, session)
|
|
410
|
+
text = capture_screen(session)
|
|
411
|
+
if text && !text.empty?
|
|
412
|
+
sent = say_chunked(chat_id, "", text, code: true)
|
|
413
|
+
return sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
show_agent_context(chat_id, session)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def capture_screen(session)
|
|
420
|
+
@registry.backend_for(session).capture(session.id,
|
|
421
|
+
lines: @config.get("terminal.context_lines", 80))
|
|
422
|
+
rescue Terminals::Unsupported
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def show_agent_context(chat_id, session)
|
|
427
|
+
transcript = Transcript.for_cwd(session.cwd, root: transcript_root)
|
|
428
|
+
|
|
429
|
+
return say(chat_id, "The screen is empty, and no transcript was found.") unless transcript.exists?
|
|
430
|
+
|
|
431
|
+
sent = say_chunked(chat_id, "📄 #{session.label} (transcript)\n\n", transcript.render)
|
|
432
|
+
sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def close_session(chat_id, session)
|
|
436
|
+
ok = @registry.backend_for(session).close(session.id)
|
|
437
|
+
say(chat_id, ok ? "Closed #{session.label}." : "Couldn't close it.")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def with_session(chat_id, number)
|
|
441
|
+
id = @keyboards.session_for(chat_id, number.to_s.strip)
|
|
442
|
+
return say(chat_id, "No such number — pull up the list again.") unless id
|
|
443
|
+
|
|
444
|
+
session = @registry.refresh.find(id)
|
|
445
|
+
return say(chat_id, "That session is already closed.") unless session
|
|
446
|
+
|
|
447
|
+
yield(session)
|
|
448
|
+
rescue Terminals::Unsupported => e
|
|
449
|
+
say(chat_id, e.message)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
HEADER_HEADROOM = 300
|
|
453
|
+
|
|
454
|
+
# A long response goes out as multiple messages (Chunker), never
|
|
455
|
+
# clipped. code: true wraps each chunk in its own code block —
|
|
456
|
+
# otherwise a split between chunks would leave unclosed triple quotes.
|
|
457
|
+
def say_chunked(chat_id, header, body, markup: nil, code: false)
|
|
458
|
+
chunks = Chunker.split(body, limit: MAX_MESSAGE - HEADER_HEADROOM)
|
|
459
|
+
chunks = [""] if chunks.empty?
|
|
460
|
+
|
|
461
|
+
chunks.each_with_index.map do |chunk, i|
|
|
462
|
+
content = code ? "```\n#{chunk}\n```" : chunk
|
|
463
|
+
prefix = i.zero? ? header : "↪️ #{i + 1}/#{chunks.size}\n\n"
|
|
464
|
+
say(chat_id, "#{prefix}#{content}", markup: i == chunks.size - 1 ? markup : nil)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# MarkdownV2 is strict: one incorrectly escaped period and
|
|
469
|
+
# Telegram rejects the whole send, so there's always a fallback
|
|
470
|
+
# to plain text here.
|
|
471
|
+
def say(chat_id, text, markup: nil)
|
|
472
|
+
@api.send_message(chat_id: chat_id, text: Markdown.convert(text),
|
|
473
|
+
reply_markup: markup, parse_mode: "MarkdownV2")
|
|
474
|
+
rescue Api::Error => e
|
|
475
|
+
raise unless e.message.include?("can't parse entities")
|
|
476
|
+
|
|
477
|
+
@api.send_message(chat_id: chat_id, text: text, reply_markup: markup)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
module Channels
|
|
5
|
+
module Telegram
|
|
6
|
+
# The settings menu inside the bot.
|
|
7
|
+
#
|
|
8
|
+
# Settings are needed here, not just in the CLI: they need
|
|
9
|
+
# changing exactly when the keyboard is out of reach. The token is
|
|
10
|
+
# the exception — it's set only via the CLI, because without it
|
|
11
|
+
# there's no way to talk to the bot at all.
|
|
12
|
+
class SettingsMenu
|
|
13
|
+
# What can be toggled. Order is display order.
|
|
14
|
+
TOGGLES = [
|
|
15
|
+
{ key: "answers.away", label: "Mode",
|
|
16
|
+
on: "🚶 away", off: "🪑 present", default: false },
|
|
17
|
+
{ key: "answers.auto_continue", label: "Auto-reply \"continue\"",
|
|
18
|
+
on: "✅ on", off: "❌ off", default: true },
|
|
19
|
+
{ key: "answers.auto_approve_permissions", label: "Auto-approve tools",
|
|
20
|
+
on: "⚠️ on", off: "❌ off", default: false },
|
|
21
|
+
{ key: "answers.notify_when_present", label: "Notify while present",
|
|
22
|
+
on: "✅ on", off: "❌ off", default: true },
|
|
23
|
+
{ key: "anchors.enabled", label: "Rate-limit anchors",
|
|
24
|
+
on: "✅ on", off: "❌ off", default: false }
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Numeric settings: cycling through a step instead of typing text
|
|
28
|
+
# — noticeably faster on a phone.
|
|
29
|
+
CHOICES = [
|
|
30
|
+
{ key: "answers.reply_timeout", label: "Reply timeout",
|
|
31
|
+
values: [300, 600, 900, 1800, 3600], unit: "s", default: 900 },
|
|
32
|
+
{ key: "terminal.context_lines", label: "Context lines",
|
|
33
|
+
values: [40, 80, 200, 500], unit: "", default: 80 }
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(store:, config:)
|
|
37
|
+
@store = store
|
|
38
|
+
@config = config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# List rows without a header — the console shows the same rows
|
|
42
|
+
# in a menu navigated with arrow keys.
|
|
43
|
+
def rows
|
|
44
|
+
(TOGGLES + CHOICES).map { |item| "#{item[:label]}: #{value_label(item)}" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text
|
|
48
|
+
(["⚙️ Settings", ""] + rows + ["", warning].compact).join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def markup
|
|
52
|
+
rows = TOGGLES.map { |item| [toggle_button(item)] }
|
|
53
|
+
rows += CHOICES.map { |item| [choice_button(item)] }
|
|
54
|
+
|
|
55
|
+
{ inline_keyboard: rows }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apply a press and return what changed, to show the human.
|
|
59
|
+
def apply(payload)
|
|
60
|
+
item = find(payload["key"])
|
|
61
|
+
return nil unless item
|
|
62
|
+
|
|
63
|
+
@config.set(item[:key], next_value(item)).save
|
|
64
|
+
|
|
65
|
+
"#{item[:label]}: #{value_label(item)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def find(key) = (TOGGLES + CHOICES).find { |item| item[:key] == key }
|
|
71
|
+
|
|
72
|
+
def current(item) = @config.get(item[:key], item[:default])
|
|
73
|
+
|
|
74
|
+
def next_value(item)
|
|
75
|
+
return !current(item) unless item[:values]
|
|
76
|
+
|
|
77
|
+
values = item[:values]
|
|
78
|
+
values[(values.index(current(item)).to_i + 1) % values.size]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def value_label(item)
|
|
82
|
+
return "#{current(item)}#{item[:unit].empty? ? '' : " #{item[:unit]}"}" if item[:values]
|
|
83
|
+
|
|
84
|
+
current(item) ? item[:on] : item[:off]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def toggle_button(item)
|
|
88
|
+
{ text: "#{item[:label]}: #{value_label(item)}",
|
|
89
|
+
callback_data: action(item) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def choice_button(item)
|
|
93
|
+
{ text: "#{item[:label]}: #{value_label(item)} →",
|
|
94
|
+
callback_data: action(item) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def action(item)
|
|
98
|
+
@store.put({ "action" => "setting", "key" => item[:key] }, ttl: 3600)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Auto-approve is the one setting that hands the agent
|
|
102
|
+
# permissions with nobody around. Worth a reminder right in the menu.
|
|
103
|
+
def warning
|
|
104
|
+
return nil unless @config.get("answers.auto_approve_permissions", false)
|
|
105
|
+
|
|
106
|
+
"⚠️ Auto-approve is on: the agent runs tools without asking.\n" \
|
|
107
|
+
"Blocked commands still ask regardless."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|