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