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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ module Telegram
6
+ # The long-polling loop.
7
+ #
8
+ # Webhooks are deliberately not used: they need a public address
9
+ # and a certificate. Long polling works behind NAT, from a coffee
10
+ # shop, and with the laptop lid closed β€” everywhere this tool
11
+ # actually lives.
12
+ class Bot
13
+ OFFSET_KEY = "telegram:offset"
14
+
15
+ # Pause after a network failure: grows toward a ceiling so a
16
+ # closed laptop doesn't hammer reconnects all night.
17
+ BACKOFF = [1, 2, 5, 10, 30, 60].freeze
18
+
19
+ def initialize(api:, router:, store:, config:, logger: $stdout)
20
+ @api = api
21
+ @router = router
22
+ @store = store
23
+ @config = config
24
+ @logger = logger
25
+ @running = false
26
+ end
27
+
28
+ # Polling runs on its own thread while the main one waits for a
29
+ # stop signal: otherwise Ctrl-C has no effect while a long poll
30
+ # is hanging, and launchd gets to send SIGKILL first. Kept
31
+ # separate from wait so an interactive console can hold its own
32
+ # input loop while the bot runs alongside.
33
+ def start
34
+ @running = true
35
+ @stopped = Thread::Queue.new
36
+ @poller = Thread.new { poll_loop }
37
+
38
+ self
39
+ end
40
+
41
+ # Wait for a stop. Returns false if the reason was a second
42
+ # instance on the same token.
43
+ def wait
44
+ reason = @stopped.pop
45
+
46
+ @running = false
47
+ @poller&.kill
48
+ log(reason) if reason.is_a?(String)
49
+
50
+ reason != :conflict
51
+ end
52
+
53
+ def run
54
+ start
55
+ trap_signals
56
+ log("bot started, listening for updates")
57
+ wait
58
+ end
59
+
60
+ def stop = @stopped&.push(nil)
61
+
62
+ def running? = @running
63
+
64
+ private
65
+
66
+ def poll_loop
67
+ failures = 0
68
+
69
+ while @running
70
+ begin
71
+ poll_once
72
+ failures = 0
73
+ rescue Api::Conflict => e
74
+ # Another process is reading updates β€” looping further is
75
+ # pointless, exit with a clear explanation.
76
+ log("stopping: #{e.message}. Is another agents_control running?")
77
+ @stopped.push(:conflict)
78
+ return
79
+ rescue Api::TooManyRequests => e
80
+ pause(e.retry_after.positive? ? e.retry_after : 5)
81
+ rescue Api::Unavailable => e
82
+ failures += 1
83
+ log("network unavailable (#{e.message}), retrying in #{backoff(failures)}s")
84
+ pause(backoff(failures))
85
+ rescue Api::Error => e
86
+ failures += 1
87
+ log("API error: #{e.message}")
88
+ pause(backoff(failures))
89
+ end
90
+ end
91
+ end
92
+
93
+ def poll_once
94
+ updates = @api.get_updates(offset: offset, timeout: @config.get("telegram.poll_timeout", 30))
95
+
96
+ updates.each do |update|
97
+ handle(update)
98
+ self.offset = update["update_id"] + 1
99
+ end
100
+ end
101
+
102
+ # The offset advances even after a failed handling attempt: not
103
+ # advancing it would mean replaying the same message forever.
104
+ # Network errors don't reach here β€” they bubble up out of
105
+ # get_updates before the loop.
106
+ def handle(update)
107
+ @router.handle(update)
108
+ rescue Api::Unavailable
109
+ raise
110
+ rescue StandardError => e
111
+ log("failed to handle update #{update['update_id']}: #{e.class}: #{e.message}")
112
+ end
113
+
114
+ def offset = @store.get(OFFSET_KEY)
115
+
116
+ # The offset outlives buttons: it has to survive both a restart
117
+ # and an overnight idle period, or the bot would replay old commands.
118
+ def offset=(value)
119
+ @store.put(value, ttl: 86_400 * 30, key: OFFSET_KEY)
120
+ end
121
+
122
+ def backoff(failures) = BACKOFF[[failures - 1, BACKOFF.size - 1].min]
123
+
124
+ # Sleep in short slices so Ctrl-C doesn't have to wait out the full pause.
125
+ # Not named wait β€” that's the name of the public stop-waiting method.
126
+ def pause(seconds)
127
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
128
+
129
+ while @running && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
130
+ sleep(0.2)
131
+ end
132
+ end
133
+
134
+ # A signal handler can safely do almost nothing β€” Queue#push is
135
+ # one of the few things that's actually safe there.
136
+ def trap_signals
137
+ %w[INT TERM].each do |signal|
138
+ Signal.trap(signal) { @stopped.push("received signal #{signal}, stopping") }
139
+ end
140
+ end
141
+
142
+ def log(message) = @logger.puts("[#{Time.now.strftime('%H:%M:%S')}] #{message}")
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ module Telegram
6
+ # Telegram as a notification and reply channel.
7
+ #
8
+ # Deliberately split from Router: Router parses incoming traffic,
9
+ # Channel produces outgoing traffic. Both share the same Store,
10
+ # because a button physically can't carry more than 64 bytes, and
11
+ # the whole content of an action lives on our side anyway.
12
+ class Channel < Channels::Base
13
+ def initialize(api:, store:, config:, registry: nil)
14
+ @api = api
15
+ @store = store
16
+ @config = config
17
+ @registry = registry
18
+ end
19
+
20
+ def ready? = !chats.empty?
21
+
22
+ # Notify, expecting nothing back.
23
+ def notify(event)
24
+ return notify_ask_user_question(event) if event.ask_user_question?
25
+
26
+ broadcast(headline(event), event: event)
27
+ end
28
+
29
+ # Ask and wait. Blocks the calling thread β€” and through it, the
30
+ # agent itself, which is holding the hook's HTTP request open.
31
+ #
32
+ # Never called with an AskUserQuestion event: Dispatcher routes
33
+ # those through notify instead, since their answer never flows
34
+ # through a hook response in the first place.
35
+ def ask(event, pending:, timeout:)
36
+ pending.ask(event, timeout: timeout) do |question_id|
37
+ broadcast(question_text(event), markup: buttons_for(event, question_id),
38
+ event: event, question_id: question_id)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def registry = @registry ||= Registry.new
45
+
46
+ def chats = Array(@config.get("telegram.allowed_chat_ids", []))
47
+
48
+ # Headroom for the continuation marker ("β†ͺ️ 2/3\n\n"), which is
49
+ # appended to a chunk after Chunker has already measured its length.
50
+ MARKER_HEADROOM = 20
51
+
52
+ # Buttons and the "message β†’ session" link are attached to every
53
+ # chunk: a reply can target any of them, not just the last one.
54
+ def broadcast(text, markup: nil, event: nil, question_id: nil)
55
+ chunks = Chunker.split(text, limit: Chunker::MAX_MESSAGE - MARKER_HEADROOM)
56
+ chunks = [""] if chunks.empty?
57
+
58
+ chats.each do |chat_id|
59
+ chunks.each_with_index do |chunk, i|
60
+ body = "#{continuation_marker(i, chunks.size)}#{chunk}"
61
+ sent = send_formatted(chat_id, body, i == chunks.size - 1 ? markup : nil)
62
+ remember(chat_id, sent, event, question_id) if event
63
+ end
64
+ rescue Api::Error
65
+ # One unreachable chat must not affect the rest, and
66
+ # definitely must not take down the thread holding the agent.
67
+ next
68
+ end
69
+ end
70
+
71
+ def continuation_marker(index, total) = index.zero? ? "" : "β†ͺ️ #{index + 1}/#{total}\n\n"
72
+
73
+ # MarkdownV2 is strict: one incorrectly escaped period and
74
+ # Telegram rejects the whole send, so there's a fallback to plain
75
+ # text. Losing a message to a formatting error isn't an option:
76
+ # on the other end is a thread holding the hook's HTTP request open.
77
+ def send_formatted(chat_id, text, markup)
78
+ @api.send_message(chat_id: chat_id, text: Markdown.convert(text),
79
+ reply_markup: markup, parse_mode: "MarkdownV2")
80
+ rescue Api::Error => e
81
+ raise unless e.message.include?("can't parse entities")
82
+
83
+ @api.send_message(chat_id: chat_id, text: text, reply_markup: markup)
84
+ end
85
+
86
+ # The link between a chat message and the session it's about.
87
+ #
88
+ # Needed so an agent's message can just be replied to, without
89
+ # figuring out tab numbers. It outlives the question itself:
90
+ # replying to an old message still makes sense even after the
91
+ # agent has stopped waiting.
92
+ #
93
+ # TTL is 30 days, not a day: the whole point is answering not
94
+ # right away but whenever convenient, and an old message has to stay addressable.
95
+ REPLY_TARGET_TTL = 30 * 86_400
96
+
97
+ def remember(chat_id, sent, event, question_id)
98
+ id = sent.is_a?(Hash) ? sent["message_id"] : nil
99
+ return unless id
100
+
101
+ @store.put({ "session_id" => event.session_id, "question_id" => question_id,
102
+ "cwd" => event.cwd, "label" => event.label },
103
+ ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
104
+ end
105
+
106
+ # A short session tag in the header. Needed so that in a chat
107
+ # with several agents it's clear which one a message belongs to
108
+ # β€” especially when they work in the same directory and the
109
+ # label is identical.
110
+ def tag(event) = "##{event.session_id.to_s.delete('-')[0, 4]}"
111
+
112
+ def headline(event)
113
+ case event.kind
114
+ when :error then "πŸ”΄ #{event.label} #{tag(event)}\n#{event.text}"
115
+ when :finished then "βœ… #{event.label} #{tag(event)} β€” done"
116
+ else "πŸ”” #{event.label} #{tag(event)}\n#{event.summary}"
117
+ end
118
+ end
119
+
120
+ # The reply hint lives right in the message: nobody's going to
121
+ # look it up in the help text.
122
+ def question_text(event)
123
+ head = case event.kind
124
+ when :needs_permission
125
+ "πŸ” #{event.label} #{tag(event)} needs permission\n\n`#{event.summary}`"
126
+ else
127
+ "❓ #{event.label} #{tag(event)} is waiting for a reply\n\n#{event.text.to_s}"
128
+ end
129
+
130
+ "#{head}\n\n↩️ reply to this message to write to the agent"
131
+ end
132
+
133
+ def buttons_for(event, question_id)
134
+ rows = action_rows(event, question_id)
135
+ rows << [context_button(event, question_id)]
136
+
137
+ { inline_keyboard: rows }
138
+ end
139
+
140
+ def action_rows(event, question_id)
141
+ event.kind == :needs_permission ? permission_rows(question_id) : input_rows(question_id)
142
+ end
143
+
144
+ def permission_rows(question_id)
145
+ [
146
+ [answer_button("βœ… Allow", question_id, kind: :allow),
147
+ answer_button("❌ Deny", question_id, kind: :deny)],
148
+ [answer_button("βœ… Allow and don't ask again", question_id,
149
+ kind: :allow, remember: true)]
150
+ ]
151
+ end
152
+
153
+ def input_rows(question_id)
154
+ [
155
+ [answer_button("▢️ Continue", question_id, kind: :text, text: "Continue."),
156
+ answer_button("⏹ Stop", question_id, kind: :text, text: "Stop and wait for me.")],
157
+ [{ text: "✍️ Answer in my own words",
158
+ callback_data: @store.put({ "action" => "compose", "question_id" => question_id },
159
+ ttl: ttl) }]
160
+ ]
161
+ end
162
+
163
+ # Show the tail of the conversation β€” for when a single line
164
+ # doesn't make it clear what's going on. Context shouldn't be
165
+ # dumped by default: it's a tool for when in doubt, not a
166
+ # mandatory part of every notification.
167
+ def context_button(event, _question_id)
168
+ {
169
+ text: "πŸ“„ context",
170
+ callback_data: @store.put({ "action" => "transcript",
171
+ "path" => event.transcript_path,
172
+ "label" => event.label }, ttl: ttl)
173
+ }
174
+ end
175
+
176
+ def answer_button(text, question_id, kind:, remember: false, text_value: nil, **extra)
177
+ payload = {
178
+ "action" => "answer",
179
+ "question_id" => question_id,
180
+ "reply" => { "kind" => kind.to_s, "remember" => remember,
181
+ "text" => extra[:text] || text_value }
182
+ }
183
+
184
+ { text: text, callback_data: @store.put(payload, ttl: ttl) }
185
+ end
186
+
187
+ def ttl = @config.get("answers.reply_timeout", 600) + 300
188
+
189
+ # ── AskUserQuestion ────────────────────────────────────────────
190
+
191
+ # This never blocks the hook and never carries a question_id:
192
+ # the answer doesn't come back as a hook decision, it gets typed
193
+ # straight into the terminal β€” by a button tap (ask_question_choice,
194
+ # handled in Router) or by replying with free text (already
195
+ # routed there by Router#type_into_session, since there's no
196
+ # pending question tied to this message for it to be mistaken for).
197
+ def notify_ask_user_question(event)
198
+ text = "❓ #{event.label} #{tag(event)}\n\n```\n#{event.summary}\n```\n\n" \
199
+ "↩️ reply to this message to answer in your own words"
200
+
201
+ rows = option_rows(event)
202
+ rows << [context_button(event, nil)]
203
+
204
+ broadcast(text, markup: { inline_keyboard: rows }, event: event)
205
+ end
206
+
207
+ # Only offered when there's exactly one question and exactly one
208
+ # matching terminal session for its cwd. With more than one
209
+ # question we don't know whether the terminal expects them
210
+ # answered one at a time or needs a final submit; with an
211
+ # ambiguous cwd we don't know which pane to type into. Both
212
+ # cases fall back to no option buttons β€” replying still works either way.
213
+ def option_rows(event)
214
+ questions = Array(event.tool_input.is_a?(Hash) ? event.tool_input["questions"] : nil)
215
+ return [] unless questions.size == 1
216
+
217
+ session = resolve_session(event)
218
+ return [] unless session
219
+
220
+ option_buttons(session, questions.first)
221
+ end
222
+
223
+ def resolve_session(event)
224
+ matches = registry.refresh.agents.select { |s| s.cwd == event.cwd && !s.terminalless? }
225
+ matches.size == 1 ? matches.first : nil
226
+ end
227
+
228
+ # Options that read as an open-ended "type your own answer"
229
+ # invitation rather than a concrete choice get skipped here β€”
230
+ # tapping a button for one would be meaningless, since there's
231
+ # nothing to type into the terminal on the user's behalf.
232
+ # Replying with free text already covers this case regardless.
233
+ OPEN_ENDED_OPTION = /\b(other|something else|none of (?:these|the above)|custom|
234
+ explain|describe|my own|not (?:listed|here))\b/xi
235
+
236
+ def option_buttons(session, question)
237
+ Array(question["options"]).each_with_index.filter_map do |option, index|
238
+ next if open_ended?(option)
239
+
240
+ [{ text: "#{index + 1}. #{option['label']}"[0, 60],
241
+ callback_data: @store.put({ "action" => "ask_question_choice",
242
+ "session_id" => session.id, "choice" => index + 1 },
243
+ ttl: ttl) }]
244
+ end
245
+ end
246
+
247
+ def open_ended?(option) = "#{option['label']} #{option['description']}".match?(OPEN_ENDED_OPTION)
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ module Telegram
6
+ # Splits long text into multiple messages instead of clipping the
7
+ # tail or the head β€” an agent's response is never allowed to be cut short.
8
+ #
9
+ # Telegram's real limit is 4096 UTF-16 code units in the `text`
10
+ # field AFTER entity parsing (core.telegram.org/bots/api#sendmessage,
11
+ # the unit is confirmed at core.telegram.org/api/entities). That's
12
+ # neither UTF-8 bytes nor `String#length` (codepoint count): a
13
+ # non-Latin character is 2 bytes in UTF-8 but 1 unit in UTF-16, so a
14
+ # byte-based estimate is up to twice as conservative as necessary
15
+ # for any non-ASCII text.
16
+ module Chunker
17
+ MAX_MESSAGE = 4096
18
+
19
+ module_function
20
+
21
+ def utf16_length(text) = text.to_s.encode(Encoding::UTF_16LE).bytesize / 2
22
+
23
+ # The RAW text has to be cut, not text already converted to
24
+ # MarkdownV2: a cut inside an escaping pair or a code block would
25
+ # make Telegram reject the whole chunk's parsing. So the chunk
26
+ # boundary is chosen on the raw lines, but measured against the
27
+ # final, converted length β€” exactly what the API will see.
28
+ def split(text, limit: MAX_MESSAGE)
29
+ lines = text.to_s.each_line.flat_map { |line| fit_line(line, limit) }
30
+ pack(lines, limit)
31
+ end
32
+
33
+ # A single line longer than the limit on its own (minified JSON
34
+ # with no line breaks, say) leaves no line boundary to cut at, so
35
+ # it's cut by character with a 2x safety margin: MarkdownV2
36
+ # escaping adds at most one backslash per character, so it can
37
+ # never more than double the text's length.
38
+ def fit_line(line, limit)
39
+ return [line] if utf16_length(line) <= limit
40
+
41
+ line.chars.each_slice(limit / 2).map(&:join)
42
+ end
43
+
44
+ def pack(pieces, limit)
45
+ chunks = []
46
+ current = +""
47
+
48
+ pieces.each do |piece|
49
+ candidate = current + piece
50
+ if !current.empty? && utf16_length(Markdown.convert(candidate)) > limit
51
+ chunks << current
52
+ current = piece
53
+ else
54
+ current = candidate
55
+ end
56
+ end
57
+ chunks << current unless current.empty?
58
+
59
+ chunks
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ module Telegram
6
+ # Building inline keyboards and rendering lists.
7
+ #
8
+ # The key Bot API constraint: a button's `callback_data` can't
9
+ # exceed **64 bytes**. An iTerm2 session UUID alone (36 characters,
10
+ # plus an action, plus separators) already cuts it close, let alone
11
+ # a path or a command.
12
+ #
13
+ # So a button carries only a short key from Store, and the actual
14
+ # action lives on the daemon's side. Side benefit: the key is
15
+ # one-shot, and pressing the same button twice does nothing the
16
+ # second time.
17
+ class Keyboards
18
+ # How long a button lives. It has to survive a quick trip out,
19
+ # but not hang around forever: a stale message shouldn't stay a
20
+ # working remote control.
21
+ ACTION_TTL = 3600
22
+
23
+ def initialize(store:)
24
+ @store = store
25
+ end
26
+
27
+ # Buttons under a single session's card.
28
+ def session_actions(session)
29
+ rows = [[
30
+ button("🎯 focus", action: "focus", session: session),
31
+ button("πŸ‘ screen", action: "screen", session: session)
32
+ ]]
33
+
34
+ # Closing a tab goes through confirmation: a mistap on a phone is too easy.
35
+ rows << [button("βœ–οΈ close", action: "close_confirm", session: session)] unless session.terminalless?
36
+
37
+ { inline_keyboard: rows }
38
+ end
39
+
40
+ def confirm(action, session, label: "Yes, close it")
41
+ {
42
+ inline_keyboard: [[
43
+ button("⚠️ #{label}", action: action, session: session),
44
+ button("cancel", action: "cancel", session: session)
45
+ ]]
46
+ }
47
+ end
48
+
49
+ def list_actions
50
+ { inline_keyboard: [[
51
+ { text: "πŸ€– agents", callback_data: put(action: "list_agents") },
52
+ { text: "πŸ“‹ all tabs", callback_data: put(action: "list_tabs") }
53
+ ]] }
54
+ end
55
+
56
+ # Buttons under /start and /help β€” the full command list isn't
57
+ # always visible in the Telegram UI (the menu button is easy to
58
+ # miss among everything else), so this keeps it available at any
59
+ # moment right in the message.
60
+ def main_menu
61
+ { inline_keyboard: [
62
+ [{ text: "πŸ€– agents", callback_data: put(action: "list_agents") },
63
+ { text: "πŸ“‹ all tabs", callback_data: put(action: "list_tabs") }],
64
+ [{ text: "πŸ“Š status", callback_data: put(action: "status") },
65
+ { text: "βš™οΈ settings", callback_data: put(action: "show_settings") }]
66
+ ] }
67
+ end
68
+
69
+ # A list with short numbers: commands like `/run 2 ls` address
70
+ # sessions by them later.
71
+ #
72
+ # Numbers are assigned against the full session list (universe),
73
+ # not the displayed subset β€” "All tabs" and "Agents" both number
74
+ # against the same universe, so a session keeps the same number
75
+ # in either view as long as the set of tabs hasn't changed. An
76
+ # /run with a remembered number must always hit the session the
77
+ # human actually saw, never a different one that happened to
78
+ # land on the same row in a shorter, filtered list.
79
+ def render_list(sessions, chat_id:, title:, universe: sessions)
80
+ numbers = numbered(universe)
81
+ @store.put(numbers.invert, ttl: ACTION_TTL, key: index_key(chat_id))
82
+
83
+ lines = sessions.map do |session|
84
+ "#{numbers[session.id].to_s.rjust(2)}. #{marker(session)} #{describe(session)}"
85
+ end
86
+
87
+ ([title, ""] + lines).join("\n")
88
+ end
89
+
90
+ def session_for(chat_id, number)
91
+ map = @store.get(index_key(chat_id)) || {}
92
+
93
+ map[number.to_s]
94
+ end
95
+
96
+ private
97
+
98
+ def index_key(chat_id) = "index:#{chat_id}"
99
+
100
+ # session.id => "1".."N", in the order of the full session list.
101
+ def numbered(universe)
102
+ universe.each_with_index.to_h { |session, position| [session.id, (position + 1).to_s] }
103
+ end
104
+
105
+ def button(text, action:, session:)
106
+ { text: text, callback_data: put(action: action, session_id: session.id) }
107
+ end
108
+
109
+ def put(payload) = @store.put(payload, ttl: ACTION_TTL)
110
+
111
+ def marker(session)
112
+ return "⏳" if session.processing?
113
+ return "πŸ–₯" if session.terminalless?
114
+ return "β–Έ" if session.at_shell_prompt?
115
+
116
+ "Β·"
117
+ end
118
+
119
+ def describe(session)
120
+ parts = [session.agent ? session.agent.to_s : (session.foreground_command || "β€”")]
121
+ parts << session.label
122
+ parts << (session.terminalless? ? "vscode" : session.tty.to_s.sub(%r{\A/dev/}, ""))
123
+
124
+ parts.join(" Β· ")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ module Telegram
6
+ # Turns plain GFM-like text β€” the shape Claude Code writes β€” into
7
+ # Telegram MarkdownV2.
8
+ #
9
+ # Escaping rules:
10
+ #
11
+ # - outside code, the 18 characters `_*[]()~`>#+-=|{}.!` must be
12
+ # backslash-escaped, or sendMessage answers with a 400: Bad
13
+ # Request: can't parse entities: Character '.' is reserved…
14
+ # - inside `inline code` and ```blocks``` those same characters
15
+ # must NOT be escaped β€” there they're just text;
16
+ # - a ```lang tag before a block is accepted and syntax-highlighted.
17
+ #
18
+ # This is Telegram's strict mode: one incorrectly escaped period
19
+ # and the whole message fails to send. So the converter is written
20
+ # conservatively (it never touches code, and escapes prose in
21
+ # full), and the Api side still carries its own separate
22
+ # safety net β€” falling back to plain text if Telegram rejects it anyway.
23
+ module Markdown
24
+ # Escaped outside code. Order matters: `\` goes first, or the
25
+ # escaping slashes would themselves get escaped again.
26
+ RESERVED = %w[\\ _ * [ ] ( ) ~ ` > # + - = | { } . !].freeze
27
+
28
+ # Code is what must never be touched: even the opening triple
29
+ # quote can carry a language tag (```ruby) that has to be kept
30
+ # literal, not escaped.
31
+ CODE = /(```.*?```|`[^`\n]+`)/m
32
+
33
+ # One of the few GFM constructs worth preserving as actual
34
+ # formatting rather than turning into escaped asterisks: Claude
35
+ # Code often puts **important** text in headings and summaries.
36
+ BOLD = /\*\*(.+?)\*\*/m
37
+
38
+ module_function
39
+
40
+ def convert(text)
41
+ text.to_s.split(CODE).each_with_index.map do |chunk, index|
42
+ index.odd? ? chunk : escape_prose(chunk)
43
+ end.join
44
+ end
45
+
46
+ def escape_prose(text)
47
+ text.split(BOLD).each_with_index.map do |chunk, index|
48
+ index.odd? ? "*#{escape_literal(chunk)}*" : escape_literal(chunk)
49
+ end.join
50
+ end
51
+
52
+ def escape_literal(text)
53
+ pattern = Regexp.union(RESERVED)
54
+ text.gsub(pattern) { |char| "\\#{char}" }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end