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