agents_control 0.1.0 → 0.2.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 +4 -4
- data/README.md +73 -39
- data/lib/agents_control/channels/telegram/keyboards.rb +10 -1
- data/lib/agents_control/channels/telegram/router.rb +157 -16
- data/lib/agents_control/channels/telegram/settings_menu.rb +3 -1
- data/lib/agents_control/daemon.rb +1 -1
- data/lib/agents_control/version.rb +1 -1
- metadata +10 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b71d165050803893091b3bce0857cb4a9cac7875e28289e0aaebc6bf4a1aa820
|
|
4
|
+
data.tar.gz: a301e0e972625e0eb57a79e80799e21edf2232d24ef8bbbfb74840484d1feb7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6a5dd4eb521026c1084ade954fbe7d7a8f26426e3d68d78077a3d69e71addc0c40165181eaaff963a225434a71167e0ed02f68382fe8ed4605a46f8e781bc54
|
|
7
|
+
data.tar.gz: 7077e2aa298e86630f4c619e67a6bde0be85a2316f86c43b46dbd354353bc9404f91a0db8aa86ab9f8fcf1f0c3d6e8cee101c1b55cbf53080810f52d2ba37df1
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# agents_control
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/agents_control)
|
|
4
|
+
|
|
3
5
|
A remote for iTerm2 and its AI agents (Claude Code, Codex) from Telegram:
|
|
4
6
|
tab list, commands, screen, new sessions. For Claude Code — also the
|
|
5
7
|
agent's questions and permission requests: it stops, buttons show up in
|
|
@@ -26,15 +28,15 @@ outside: the port only binds to 127.0.0.1, there are no incoming connections.
|
|
|
26
28
|
On macOS, terminal control (sending commands, reading the screen,
|
|
27
29
|
creating tabs) works through iTerm2 or tmux — Terminal.app isn't
|
|
28
30
|
supported. Notifications about questions and permissions aren't tied to
|
|
29
|
-
a terminal at all: their source is
|
|
31
|
+
a terminal at all: their source is Claude Code's hooks, which work
|
|
30
32
|
everywhere, including sessions with no terminal (a VS Code session, for
|
|
31
|
-
instance)
|
|
33
|
+
instance).
|
|
32
34
|
|
|
33
35
|
## Why
|
|
34
36
|
|
|
35
37
|
You need to run a command in a session, check the screen, or switch to
|
|
36
38
|
a tab, and you're not at the computer — now you can do that from
|
|
37
|
-
Telegram. If the session is Claude Code, there's a bonus too:
|
|
39
|
+
Telegram. If the session is Claude Code, there's a bonus too: it
|
|
38
40
|
stops and waits for an answer — the question and its buttons arrive in
|
|
39
41
|
Telegram, no need to go home just to say "continue."
|
|
40
42
|
|
|
@@ -59,6 +61,12 @@ cd agents_control
|
|
|
59
61
|
bundle install
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
Or as a RubyGem:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
gem install agents_control
|
|
68
|
+
```
|
|
69
|
+
|
|
62
70
|
Or straight from Claude Code, as a plugin — the repo doubles as a marketplace:
|
|
63
71
|
|
|
64
72
|
```
|
|
@@ -69,31 +77,7 @@ Or straight from Claude Code, as a plugin — the repo doubles as a marketplace:
|
|
|
69
77
|
The plugin doesn't replace the install above — it's just a way to find
|
|
70
78
|
the tool and get install instructions without leaving Claude Code.
|
|
71
79
|
|
|
72
|
-
##
|
|
73
|
-
|
|
74
|
-
```sh
|
|
75
|
-
agents_control # opens the console and stays in the tab
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
The tool lives in a tab: while it's open, it listens to Telegram and
|
|
79
|
-
receives agent events. Commands inside start with a slash, same as the bot's:
|
|
80
|
-
|
|
81
|
-
```
|
|
82
|
-
> /sessions sessions with a live agent
|
|
83
|
-
> /tabs all terminal tabs
|
|
84
|
-
> /away intercept agent questions (before stepping out)
|
|
85
|
-
> /settings settings; /settings away — toggle
|
|
86
|
-
> /doctor check that everything is in place
|
|
87
|
-
> /quit quit
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
State icons: `⏳` working · `▸` at a shell prompt · `🖥` no terminal
|
|
91
|
-
(VS Code) · `·` everything else.
|
|
92
|
-
|
|
93
|
-
One-off commands exist too — `agents_control sessions`, `doctor`,
|
|
94
|
-
`daemon` — but the normal way to run it is an open console.
|
|
95
|
-
|
|
96
|
-
### Telegram
|
|
80
|
+
## Telegram
|
|
97
81
|
|
|
98
82
|
Create a bot with [@BotFather](https://t.me/BotFather) and run the wizard:
|
|
99
83
|
|
|
@@ -113,13 +97,38 @@ Bot commands:
|
|
|
113
97
|
| `/tabs` | all terminal tabs |
|
|
114
98
|
| `/screen N` | show a tab's screen |
|
|
115
99
|
| `/focus N` | switch to a tab |
|
|
116
|
-
| `/run N command` | run a command in a tab |
|
|
100
|
+
| `/run N command` | run a command in a tab and show the result |
|
|
117
101
|
| `/new [directory]` | create a tab |
|
|
118
102
|
| `/away` | intercept agent questions |
|
|
119
103
|
| `/status` | current status |
|
|
120
104
|
|
|
121
105
|
The number `N` comes from the last list shown.
|
|
122
106
|
|
|
107
|
+
`/run` treats the command itself as the reference point: the shell
|
|
108
|
+
echoes back whatever's typed, so it looks for that exact text on
|
|
109
|
+
screen and shows from there — sharper than diffing screenshots, and
|
|
110
|
+
it still works even if something else wrote to the same tab in
|
|
111
|
+
between, since it doesn't need the screen from right before typing to
|
|
112
|
+
relate to the screen after at all. A short reply ("y", "n" mid `git
|
|
113
|
+
add -p`) isn't a safe anchor on its own — too likely to match
|
|
114
|
+
something unrelated — so those fall back to a before/after diff
|
|
115
|
+
instead, and when even that can't cleanly tell what's new, to the
|
|
116
|
+
current screen outright: seeing the result, possibly with a little
|
|
117
|
+
stale context around it, beats not seeing it at all. How much gets
|
|
118
|
+
captured either way is a `/settings` option (`terminal.run_result_lines`,
|
|
119
|
+
200 by default) — a command whose output runs longer just gets its
|
|
120
|
+
last N lines, same as the default gets cut by a screen that's too tall.
|
|
121
|
+
|
|
122
|
+
The result stays a live target: replying to it — "y", "n", anything —
|
|
123
|
+
types straight into that same pane and shows what came back, so
|
|
124
|
+
something like `git add -p`'s hunk-by-hunk prompts works as an actual
|
|
125
|
+
back-and-forth over Telegram, not a one-shot fire-and-forget.
|
|
126
|
+
|
|
127
|
+
Both `/run` and `/screen` head their reply with the tab's label and
|
|
128
|
+
tty (`valkyrie · ttys017`) — several tabs can share a label when
|
|
129
|
+
they're open on the same project, and the tty is what actually tells
|
|
130
|
+
them apart.
|
|
131
|
+
|
|
123
132
|
This list also populates Telegram's own `/` command menu automatically
|
|
124
133
|
— `setup` and every daemon start publish it via the Bot API, no manual
|
|
125
134
|
BotFather step needed. If the menu still shows only `/start` after
|
|
@@ -129,15 +138,16 @@ to force it to refresh.
|
|
|
129
138
|
|
|
130
139
|
### Two modes
|
|
131
140
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
This whole section is about Claude Code specifically — its hooks are
|
|
142
|
+
what makes any of it possible. While you're at the keyboard,
|
|
143
|
+
intercepting its questions is counterproductive: you'll answer in the
|
|
144
|
+
terminal faster than you can reach for your phone, and a blocked hook
|
|
145
|
+
keeps the dialog from ever appearing on screen. So there are two modes:
|
|
136
146
|
|
|
137
147
|
- **present** (default) — questions are mirrored to Telegram but stay
|
|
138
148
|
in the terminal;
|
|
139
149
|
- **away** (`/away`) — a question arrives with buttons and waits for a
|
|
140
|
-
reply;
|
|
150
|
+
reply; Claude Code stands by until you answer or time runs out.
|
|
141
151
|
|
|
142
152
|
A question can also be answered by replying directly to the message —
|
|
143
153
|
it goes to the right session, even with several tabs open.
|
|
@@ -159,14 +169,14 @@ the wrong place.
|
|
|
159
169
|
|
|
160
170
|
A "continue" reply is sent automatically, but tool permissions aren't.
|
|
161
171
|
These are two independent settings on purpose: merged into one, they'd
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
let it approve itself everything while nobody's watching. A question
|
|
173
|
+
that offers a choice ("rewrite it or leave it?") is never answered
|
|
174
|
+
automatically, even if it contains the word "continue."
|
|
165
175
|
|
|
166
176
|
### Hooks
|
|
167
177
|
|
|
168
178
|
The daemon connects hooks on start and removes them on stop — otherwise
|
|
169
|
-
|
|
179
|
+
Claude Code prints a warning about an unreachable address in every
|
|
170
180
|
session. If the daemon crashed and the hooks are still there:
|
|
171
181
|
|
|
172
182
|
```sh
|
|
@@ -177,6 +187,30 @@ agents_control hooks uninstall
|
|
|
177
187
|
Entries in `~/.claude/settings.json` are tagged, and other settings
|
|
178
188
|
aren't touched: installing and removing return the file to exactly its original shape.
|
|
179
189
|
|
|
190
|
+
## Usage
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
agents_control # opens the console and stays in the tab
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The tool lives in a tab: while it's open, it listens to Telegram and
|
|
197
|
+
receives agent events. Commands inside start with a slash, same as the bot's:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
> /sessions sessions with a live agent
|
|
201
|
+
> /tabs all terminal tabs
|
|
202
|
+
> /away intercept agent questions (before stepping out)
|
|
203
|
+
> /settings settings; /settings away — toggle
|
|
204
|
+
> /doctor check that everything is in place
|
|
205
|
+
> /quit quit
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
State icons: `⏳` working · `▸` at a shell prompt · `🖥` no terminal
|
|
209
|
+
(VS Code) · `·` everything else.
|
|
210
|
+
|
|
211
|
+
One-off commands exist too — `agents_control sessions`, `doctor`,
|
|
212
|
+
`daemon` — but the normal way to run it is an open console.
|
|
213
|
+
|
|
180
214
|
## Rate-limit anchors
|
|
181
215
|
|
|
182
216
|
A five-hour window starts at the minute of the first message and
|
|
@@ -199,7 +233,7 @@ catches this and suggests `pmset repeat wakeorpoweron`.
|
|
|
199
233
|
|
|
200
234
|
## Watchers
|
|
201
235
|
|
|
202
|
-
Hooks see
|
|
236
|
+
Hooks see Claude Code's own decisions, but not everything: the CLI's own
|
|
203
237
|
local menus (model switch, folder trust) and text on screen (a
|
|
204
238
|
rate-limit message) aren't covered by hooks at all — these events never
|
|
205
239
|
produce a single hook call. Two independent watchers handle them,
|
|
@@ -20,6 +20,15 @@ module AgentsControl
|
|
|
20
20
|
# working remote control.
|
|
21
21
|
ACTION_TTL = 3600
|
|
22
22
|
|
|
23
|
+
# Numbers, unlike buttons, are typed by hand into later messages
|
|
24
|
+
# — /run N doesn't stay tied to the message the list came in on,
|
|
25
|
+
# so it has to survive much longer than a single quick trip out.
|
|
26
|
+
# A closed session is still handled gracefully regardless of how
|
|
27
|
+
# long the mapping lives (find just returns nil), so the only
|
|
28
|
+
# cost of a longer TTL is a number eventually pointing at a tab
|
|
29
|
+
# that's since closed — a clear, expected error either way.
|
|
30
|
+
LIST_TTL = 86_400
|
|
31
|
+
|
|
23
32
|
def initialize(store:)
|
|
24
33
|
@store = store
|
|
25
34
|
end
|
|
@@ -78,7 +87,7 @@ module AgentsControl
|
|
|
78
87
|
# land on the same row in a shorter, filtered list.
|
|
79
88
|
def render_list(sessions, chat_id:, title:, universe: sessions)
|
|
80
89
|
numbers = numbered(universe)
|
|
81
|
-
@store.put(numbers.invert, ttl:
|
|
90
|
+
@store.put(numbers.invert, ttl: LIST_TTL, key: index_key(chat_id))
|
|
82
91
|
|
|
83
92
|
lines = sessions.map do |session|
|
|
84
93
|
"#{numbers[session.id].to_s.rjust(2)}. #{marker(session)} #{describe(session)}"
|
|
@@ -35,13 +35,14 @@ module AgentsControl
|
|
|
35
35
|
HELP = (["Commands:", ""] +
|
|
36
36
|
COMMANDS.map { |name, text, hint| "/#{name}#{hint ? " #{hint}" : ''} — #{text}" }).join("\n")
|
|
37
37
|
|
|
38
|
-
def initialize(api:, registry:, store:, config:, keyboards: nil, pending: nil)
|
|
38
|
+
def initialize(api:, registry:, store:, config:, keyboards: nil, pending: nil, logger: nil)
|
|
39
39
|
@api = api
|
|
40
40
|
@registry = registry
|
|
41
41
|
@store = store
|
|
42
42
|
@config = config
|
|
43
43
|
@keyboards = keyboards || Keyboards.new(store: store)
|
|
44
44
|
@pending = pending
|
|
45
|
+
@logger = logger
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def handle(update)
|
|
@@ -95,17 +96,25 @@ module AgentsControl
|
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
# First, the exact session by session_id — unambiguous even when
|
|
98
|
-
# several tabs share one cwd
|
|
99
|
-
#
|
|
99
|
+
# several tabs share one cwd, and works for any session type:
|
|
100
|
+
# /run and /screen remember the terminal's own session id, not
|
|
101
|
+
# an agent's hook id, so this resolves directly whether the
|
|
102
|
+
# target is an agent or a plain shell tab. Matching by cwd is
|
|
103
|
+
# the fallback for when that tab has already closed — an agent
|
|
104
|
+
# match wins there if one exists (the common case: replying to
|
|
105
|
+
# an agent's own notification), otherwise any matching tab does
|
|
106
|
+
# (a plain tab mid-interactive-command has no agent to prefer).
|
|
100
107
|
def type_into_session(chat_id, target, text)
|
|
101
108
|
exact = @registry.refresh.find(target["session_id"])
|
|
102
|
-
return execute(chat_id, exact, text
|
|
109
|
+
return execute(chat_id, exact, text, show_result: !exact.agent?) if exact && !exact.terminalless?
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
candidates = @registry.sessions.select { |s| s.cwd == target["cwd"] && !s.terminalless? }
|
|
112
|
+
agent_matches = candidates.select(&:agent?)
|
|
113
|
+
matching = agent_matches.empty? ? candidates : agent_matches
|
|
105
114
|
|
|
106
115
|
case matching.size
|
|
107
116
|
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)
|
|
117
|
+
when 1 then execute(chat_id, matching.first, text, show_result: !matching.first.agent?)
|
|
109
118
|
else say(chat_id, "#{target['label']} has several tabs — use /run NUMBER to pick one.")
|
|
110
119
|
end
|
|
111
120
|
end
|
|
@@ -188,7 +197,11 @@ module AgentsControl
|
|
|
188
197
|
with_session(chat_id, number) do |session|
|
|
189
198
|
next say(chat_id, "This session has no terminal — nothing to run there.") if session.terminalless?
|
|
190
199
|
|
|
191
|
-
remote?(session)
|
|
200
|
+
if remote?(session)
|
|
201
|
+
confirm_remote(chat_id, session, command)
|
|
202
|
+
else
|
|
203
|
+
execute(chat_id, session, command, show_result: true)
|
|
204
|
+
end
|
|
192
205
|
end
|
|
193
206
|
end
|
|
194
207
|
|
|
@@ -216,13 +229,125 @@ module AgentsControl
|
|
|
216
229
|
# input: a merged call can fail to send multi-line text at all.
|
|
217
230
|
TYPING_PAUSE = 0.4
|
|
218
231
|
|
|
219
|
-
|
|
232
|
+
# How long to wait after the command lands before reading the
|
|
233
|
+
# screen back. There's no way to know when a command actually
|
|
234
|
+
# finishes without polling for it, so this is a fixed guess
|
|
235
|
+
# tuned for the common case (git status, ls, a quick build
|
|
236
|
+
# check) — long enough for those, short enough not to feel
|
|
237
|
+
# laggy. A slower command just gets caught mid-flight, same as
|
|
238
|
+
# looking over someone's shoulder while it's still running;
|
|
239
|
+
# /screen still shows the up-to-date state a moment later.
|
|
240
|
+
RUN_SETTLE_PAUSE = 1.2
|
|
241
|
+
|
|
242
|
+
def run_result_lines = @config.get("terminal.run_result_lines", 200)
|
|
243
|
+
|
|
244
|
+
def execute(chat_id, session, command, show_result: false)
|
|
220
245
|
backend = @registry.backend_for(session)
|
|
246
|
+
before = show_result ? capture_screen(session, lines: run_result_lines) : nil
|
|
221
247
|
|
|
222
248
|
ok = backend.send_text(session.id, command, newline: false) &&
|
|
223
249
|
sleep(TYPING_PAUSE).then { backend.send_text(session.id, "", newline: true) }
|
|
224
250
|
|
|
225
|
-
say(chat_id,
|
|
251
|
+
return say(chat_id, "Couldn't send.") unless ok
|
|
252
|
+
return say(chat_id, "Sent to #{session.label}.") unless show_result
|
|
253
|
+
|
|
254
|
+
sleep(RUN_SETTLE_PAUSE)
|
|
255
|
+
show_command_result(chat_id, session, since: before, command: command)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Shows what's new since `since` (the screen right before the
|
|
259
|
+
# command was typed) rather than an arbitrary tail slice of the
|
|
260
|
+
# current screen — otherwise a short result could be padded out
|
|
261
|
+
# with whatever the terminal already had on it a moment ago.
|
|
262
|
+
def show_command_result(chat_id, session, since:, command:)
|
|
263
|
+
after = capture_screen(session, lines: run_result_lines)
|
|
264
|
+
text = new_output_since(after, since, command: command)
|
|
265
|
+
log("run result: session=#{session.id} before=#{since.to_s.length}b " \
|
|
266
|
+
"after=#{after.to_s.length}b prefix_match=#{after.to_s.start_with?(since.to_s)} " \
|
|
267
|
+
"command_anchor=#{command.to_s.strip.length >= MIN_COMMAND_ANCHOR && after.to_s.include?(command.to_s.strip)} " \
|
|
268
|
+
"result=#{text.to_s.length}b")
|
|
269
|
+
|
|
270
|
+
if text && !text.empty?
|
|
271
|
+
sent = say_chunked(chat_id, "🖥 #{session_descriptor(session)}\n\n", text, code: true)
|
|
272
|
+
return sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# new_output_since only comes back empty when the capture
|
|
276
|
+
# itself was empty — a genuinely blank screen. The transcript
|
|
277
|
+
# fallback below is keyed by cwd, not by this specific session
|
|
278
|
+
# — several tabs (or VS Code sessions) can sit on the exact
|
|
279
|
+
# same project directory, and it'll pick whichever of them
|
|
280
|
+
# wrote to its transcript most recently. For a plain shell tab
|
|
281
|
+
# that's cross-session noise: a result from a totally
|
|
282
|
+
# different session shown as if it were this one's. Only
|
|
283
|
+
# worth the ambiguity when there's genuinely nothing else to
|
|
284
|
+
# offer — an agent (its own conversation is at least
|
|
285
|
+
# thematically the right session) or a session with no
|
|
286
|
+
# terminal at all (nothing else exists to show).
|
|
287
|
+
return say(chat_id, "The screen is empty.") unless session.agent? || session.terminalless?
|
|
288
|
+
|
|
289
|
+
show_agent_context(chat_id, session)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A one- or two-character reply ("y", "n", "q" mid `git add -p`)
|
|
293
|
+
# turns up constant false matches in ordinary output — not a
|
|
294
|
+
# safe anchor at all. Anything shorter than this falls straight
|
|
295
|
+
# through to the prefix/marker fallback instead.
|
|
296
|
+
MIN_COMMAND_ANCHOR = 3
|
|
297
|
+
|
|
298
|
+
# Returns whatever showed up in `after` beyond `since`.
|
|
299
|
+
#
|
|
300
|
+
# The command itself is the sharpest anchor available: the
|
|
301
|
+
# shell echoes it back verbatim, and unlike the prompt (which
|
|
302
|
+
# looks identical after every command) the command text is
|
|
303
|
+
# specific to this exact invocation. rindex, not index — the
|
|
304
|
+
# *last* occurrence is the one nearest to now, i.e. this send,
|
|
305
|
+
# not some earlier time the same command happened to run. This
|
|
306
|
+
# is also what makes it more reliable than prefix/marker
|
|
307
|
+
# matching when the tab is shared with a human actively typing
|
|
308
|
+
# in it: `since` doesn't have to relate to `after` at all for
|
|
309
|
+
# this to work, since it just locates the command's own echo
|
|
310
|
+
# directly.
|
|
311
|
+
#
|
|
312
|
+
# An exact prefix match is the fallback: unambiguous whenever
|
|
313
|
+
# nothing scrolled out of the capture window between the two
|
|
314
|
+
# snapshots, since `after` is then simply `since` with new
|
|
315
|
+
# content appended and there's nothing to search for at all.
|
|
316
|
+
#
|
|
317
|
+
# Marker search (since's own last two lines, most recent
|
|
318
|
+
# occurrence) is the last resort, for whatever's too short to
|
|
319
|
+
# anchor on and doesn't fit as a literal prefix either.
|
|
320
|
+
#
|
|
321
|
+
# No matter which method resolves it, an anchor is never
|
|
322
|
+
# allowed to produce silence: if it lands on nothing (the
|
|
323
|
+
# reference point itself, unchanged, or a boundary right at the
|
|
324
|
+
# tail), what's actually on screen right now is still real and
|
|
325
|
+
# still worth showing — the whole point of /run is seeing the
|
|
326
|
+
# result, and reporting "no new output" when there's visibly a
|
|
327
|
+
# result sitting right there is a worse failure than
|
|
328
|
+
# occasionally including a line of stale context.
|
|
329
|
+
def new_output_since(after, since, command: nil)
|
|
330
|
+
after_s = after.to_s
|
|
331
|
+
since_s = since.to_s
|
|
332
|
+
|
|
333
|
+
needle = command.to_s.strip
|
|
334
|
+
if needle.length >= MIN_COMMAND_ANCHOR
|
|
335
|
+
index = after_s.rindex(needle)
|
|
336
|
+
return after_s[index..].to_s if index
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
return after_s if since_s.strip.empty?
|
|
340
|
+
|
|
341
|
+
tail = if after_s.start_with?(since_s)
|
|
342
|
+
after_s[since_s.length..]
|
|
343
|
+
else
|
|
344
|
+
marker = since_s.rstrip.lines.last(2).join
|
|
345
|
+
index = marker.strip.empty? ? nil : after_s.rindex(marker)
|
|
346
|
+
index ? after_s[(index + marker.length)..] : nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
tail = tail.to_s.sub(/\A\n+/, "")
|
|
350
|
+
tail.empty? ? after_s : tail
|
|
226
351
|
end
|
|
227
352
|
|
|
228
353
|
def create_tab(chat_id, directory)
|
|
@@ -389,7 +514,7 @@ module AgentsControl
|
|
|
389
514
|
case payload["action"]
|
|
390
515
|
when "focus" then focus_session(chat_id, session)
|
|
391
516
|
when "screen" then show_screen(chat_id, session)
|
|
392
|
-
when "run" then execute(chat_id, session, payload["text"])
|
|
517
|
+
when "run" then execute(chat_id, session, payload["text"], show_result: true)
|
|
393
518
|
when "close_confirm" then say(chat_id, "Close #{session.label}?",
|
|
394
519
|
markup: @keyboards.confirm("close", session))
|
|
395
520
|
when "close" then close_session(chat_id, session)
|
|
@@ -406,23 +531,35 @@ module AgentsControl
|
|
|
406
531
|
# just finished lines from the transcript. The transcript stays
|
|
407
532
|
# the fallback for terminalless sessions and for a freshly
|
|
408
533
|
# created tab's blank screen.
|
|
409
|
-
def show_screen(chat_id, session)
|
|
410
|
-
text = capture_screen(session)
|
|
534
|
+
def show_screen(chat_id, session, lines: @config.get("terminal.context_lines", 80))
|
|
535
|
+
text = capture_screen(session, lines: lines)
|
|
411
536
|
if text && !text.empty?
|
|
412
|
-
sent = say_chunked(chat_id, "", text, code: true)
|
|
537
|
+
sent = say_chunked(chat_id, "🖥 #{session_descriptor(session)}\n\n", text, code: true)
|
|
413
538
|
return sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
414
539
|
end
|
|
415
540
|
|
|
541
|
+
return say(chat_id, "The screen is empty.") unless session.agent? || session.terminalless?
|
|
542
|
+
|
|
416
543
|
show_agent_context(chat_id, session)
|
|
417
544
|
end
|
|
418
545
|
|
|
419
|
-
def capture_screen(session)
|
|
420
|
-
@registry.backend_for(session).capture(session.id,
|
|
421
|
-
lines: @config.get("terminal.context_lines", 80))
|
|
546
|
+
def capture_screen(session, lines: @config.get("terminal.context_lines", 80))
|
|
547
|
+
@registry.backend_for(session).capture(session.id, lines: lines)
|
|
422
548
|
rescue Terminals::Unsupported
|
|
423
549
|
nil
|
|
424
550
|
end
|
|
425
551
|
|
|
552
|
+
# The label alone isn't always enough to tell tabs apart — several
|
|
553
|
+
# can share the same project directory and so the same label. The
|
|
554
|
+
# tty is what actually distinguishes them, and it's the same
|
|
555
|
+
# identifier a terminal's own window/tab chrome would show.
|
|
556
|
+
def session_descriptor(session)
|
|
557
|
+
return session.label if session.terminalless?
|
|
558
|
+
|
|
559
|
+
tty = session.tty.to_s.sub(%r{\A/dev/}, "")
|
|
560
|
+
tty.empty? ? session.label : "#{session.label} · #{tty}"
|
|
561
|
+
end
|
|
562
|
+
|
|
426
563
|
def show_agent_context(chat_id, session)
|
|
427
564
|
transcript = Transcript.for_cwd(session.cwd, root: transcript_root)
|
|
428
565
|
|
|
@@ -476,6 +613,10 @@ module AgentsControl
|
|
|
476
613
|
|
|
477
614
|
@api.send_message(chat_id: chat_id, text: text, reply_markup: markup)
|
|
478
615
|
end
|
|
616
|
+
|
|
617
|
+
def log(message)
|
|
618
|
+
@logger&.puts("[#{Time.now.strftime('%H:%M:%S')}] #{message}")
|
|
619
|
+
end
|
|
479
620
|
end
|
|
480
621
|
end
|
|
481
622
|
end
|
|
@@ -30,7 +30,9 @@ module AgentsControl
|
|
|
30
30
|
{ key: "answers.reply_timeout", label: "Reply timeout",
|
|
31
31
|
values: [300, 600, 900, 1800, 3600], unit: "s", default: 900 },
|
|
32
32
|
{ key: "terminal.context_lines", label: "Context lines",
|
|
33
|
-
values: [40, 80, 200, 500], unit: "", default: 80 }
|
|
33
|
+
values: [40, 80, 200, 500], unit: "", default: 80 },
|
|
34
|
+
{ key: "terminal.run_result_lines", label: "Command result lines",
|
|
35
|
+
values: [100, 200, 500, 1000], unit: "", default: 200 }
|
|
34
36
|
].freeze
|
|
35
37
|
|
|
36
38
|
def initialize(store:, config:)
|
|
@@ -234,7 +234,7 @@ module AgentsControl
|
|
|
234
234
|
def start_bot
|
|
235
235
|
router = Channels::Telegram::Router.new(
|
|
236
236
|
api: api, registry: Registry.new, store: @store,
|
|
237
|
-
config: @config, pending: pending
|
|
237
|
+
config: @config, pending: pending, logger: @logger
|
|
238
238
|
)
|
|
239
239
|
|
|
240
240
|
@bot = Channels::Telegram::Bot.new(api: api, router: router, store: @store,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agents_control
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sapar Kurmanov
|
|
@@ -24,8 +24,8 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.3'
|
|
26
26
|
description: |
|
|
27
|
-
A daemon that catches the moments
|
|
28
|
-
|
|
27
|
+
A daemon that catches the moments Claude Code stops and waits on a
|
|
28
|
+
human, and relays them to Telegram — with the question's text and
|
|
29
29
|
reply buttons. Plus a remote for the terminal itself: list tabs, create
|
|
30
30
|
them, send commands.
|
|
31
31
|
executables:
|
|
@@ -81,7 +81,12 @@ files:
|
|
|
81
81
|
homepage: https://github.com/saparjohnick/agents_control
|
|
82
82
|
licenses:
|
|
83
83
|
- Apache-2.0
|
|
84
|
-
metadata:
|
|
84
|
+
metadata:
|
|
85
|
+
source_code_uri: https://github.com/saparjohnick/agents_control
|
|
86
|
+
bug_tracker_uri: https://github.com/saparjohnick/agents_control/issues
|
|
87
|
+
documentation_uri: https://github.com/saparjohnick/agents_control/blob/main/README.md
|
|
88
|
+
changelog_uri: https://github.com/saparjohnick/agents_control/releases
|
|
89
|
+
rubygems_mfa_required: 'true'
|
|
85
90
|
rdoc_options: []
|
|
86
91
|
require_paths:
|
|
87
92
|
- lib
|
|
@@ -98,5 +103,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
98
103
|
requirements: []
|
|
99
104
|
rubygems_version: 4.0.16
|
|
100
105
|
specification_version: 4
|
|
101
|
-
summary: Control terminal AI agents from Telegram
|
|
106
|
+
summary: Control terminal AI agents (Claude Code, Codex) from Telegram
|
|
102
107
|
test_files: []
|