agents_control 0.1.0 → 0.2.1
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 +80 -39
- data/lib/agents_control/channels/telegram/keyboards.rb +10 -1
- data/lib/agents_control/channels/telegram/router.rb +165 -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: 5ca83918e01b4dc98c7b72f17da5cd31d859d863d61614f7eee74dd01e62ecb2
|
|
4
|
+
data.tar.gz: d50f30441533aded38f243800dda29a884fd01fda135506dee034115eeb63e7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9dd870ff93dd579dd1bbbc7cffd7e9f62d7a5d25ba79d2f69adb6018ac1ae8061614b5e1d09db5bc817d65f8f6a33fc7557b8a6a035245d640e92373bcdc598d
|
|
7
|
+
data.tar.gz: 71c5a463b794b2196fa3023a0a703ab6cd02da596f2b1f003ea1365db96f9a67e8d3ab3e1ddc51bdd470d76217395907565a2d53ef65dccf6fc0b411c04dee0a
|
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,45 @@ 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
|
+
Against a plain tab, `/run` treats the command itself as the reference
|
|
108
|
+
point: the shell echoes back whatever's typed, so it looks for that
|
|
109
|
+
exact text on screen and shows from there — sharper than diffing
|
|
110
|
+
screenshots, and it still works even if something else wrote to the
|
|
111
|
+
same tab in between, since it doesn't need the screen from right
|
|
112
|
+
before typing to relate to the screen after at all. A short reply
|
|
113
|
+
("y", "n" mid `git add -p`) isn't a safe anchor on its own — too
|
|
114
|
+
likely to match something unrelated — so those fall back to a
|
|
115
|
+
before/after diff instead, and when even that can't cleanly tell
|
|
116
|
+
what's new, to the current screen outright: seeing the result,
|
|
117
|
+
possibly with a little stale context around it, beats not seeing it
|
|
118
|
+
at all. How much gets captured either way is a `/settings` option
|
|
119
|
+
(`terminal.run_result_lines`, 200 by default) — a command whose output
|
|
120
|
+
runs longer just gets its last N lines, same as the default gets cut
|
|
121
|
+
by a screen that's too tall.
|
|
122
|
+
|
|
123
|
+
Against an agent, `/run` just confirms the send — an agent isn't a
|
|
124
|
+
shell command that finishes in a couple of seconds, so there's no
|
|
125
|
+
"result" to capture yet by the time it would look. Hooks already own
|
|
126
|
+
telling Telegram when it's actually done or needs something, the same
|
|
127
|
+
as replying to one of its own questions.
|
|
128
|
+
|
|
129
|
+
The result stays a live target: replying to it — "y", "n", anything —
|
|
130
|
+
types straight into that same pane and shows what came back, so
|
|
131
|
+
something like `git add -p`'s hunk-by-hunk prompts works as an actual
|
|
132
|
+
back-and-forth over Telegram, not a one-shot fire-and-forget.
|
|
133
|
+
|
|
134
|
+
Both `/run` and `/screen` head their reply with the tab's label and
|
|
135
|
+
tty (`valkyrie · ttys017`) — several tabs can share a label when
|
|
136
|
+
they're open on the same project, and the tty is what actually tells
|
|
137
|
+
them apart.
|
|
138
|
+
|
|
123
139
|
This list also populates Telegram's own `/` command menu automatically
|
|
124
140
|
— `setup` and every daemon start publish it via the Bot API, no manual
|
|
125
141
|
BotFather step needed. If the menu still shows only `/start` after
|
|
@@ -129,15 +145,16 @@ to force it to refresh.
|
|
|
129
145
|
|
|
130
146
|
### Two modes
|
|
131
147
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
This whole section is about Claude Code specifically — its hooks are
|
|
149
|
+
what makes any of it possible. While you're at the keyboard,
|
|
150
|
+
intercepting its questions is counterproductive: you'll answer in the
|
|
151
|
+
terminal faster than you can reach for your phone, and a blocked hook
|
|
152
|
+
keeps the dialog from ever appearing on screen. So there are two modes:
|
|
136
153
|
|
|
137
154
|
- **present** (default) — questions are mirrored to Telegram but stay
|
|
138
155
|
in the terminal;
|
|
139
156
|
- **away** (`/away`) — a question arrives with buttons and waits for a
|
|
140
|
-
reply;
|
|
157
|
+
reply; Claude Code stands by until you answer or time runs out.
|
|
141
158
|
|
|
142
159
|
A question can also be answered by replying directly to the message —
|
|
143
160
|
it goes to the right session, even with several tabs open.
|
|
@@ -159,14 +176,14 @@ the wrong place.
|
|
|
159
176
|
|
|
160
177
|
A "continue" reply is sent automatically, but tool permissions aren't.
|
|
161
178
|
These are two independent settings on purpose: merged into one, they'd
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
let it approve itself everything while nobody's watching. A question
|
|
180
|
+
that offers a choice ("rewrite it or leave it?") is never answered
|
|
181
|
+
automatically, even if it contains the word "continue."
|
|
165
182
|
|
|
166
183
|
### Hooks
|
|
167
184
|
|
|
168
185
|
The daemon connects hooks on start and removes them on stop — otherwise
|
|
169
|
-
|
|
186
|
+
Claude Code prints a warning about an unreachable address in every
|
|
170
187
|
session. If the daemon crashed and the hooks are still there:
|
|
171
188
|
|
|
172
189
|
```sh
|
|
@@ -177,6 +194,30 @@ agents_control hooks uninstall
|
|
|
177
194
|
Entries in `~/.claude/settings.json` are tagged, and other settings
|
|
178
195
|
aren't touched: installing and removing return the file to exactly its original shape.
|
|
179
196
|
|
|
197
|
+
## Usage
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
agents_control # opens the console and stays in the tab
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The tool lives in a tab: while it's open, it listens to Telegram and
|
|
204
|
+
receives agent events. Commands inside start with a slash, same as the bot's:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
> /sessions sessions with a live agent
|
|
208
|
+
> /tabs all terminal tabs
|
|
209
|
+
> /away intercept agent questions (before stepping out)
|
|
210
|
+
> /settings settings; /settings away — toggle
|
|
211
|
+
> /doctor check that everything is in place
|
|
212
|
+
> /quit quit
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
State icons: `⏳` working · `▸` at a shell prompt · `🖥` no terminal
|
|
216
|
+
(VS Code) · `·` everything else.
|
|
217
|
+
|
|
218
|
+
One-off commands exist too — `agents_control sessions`, `doctor`,
|
|
219
|
+
`daemon` — but the normal way to run it is an open console.
|
|
220
|
+
|
|
180
221
|
## Rate-limit anchors
|
|
181
222
|
|
|
182
223
|
A five-hour window starts at the minute of the first message and
|
|
@@ -199,7 +240,7 @@ catches this and suggests `pmset repeat wakeorpoweron`.
|
|
|
199
240
|
|
|
200
241
|
## Watchers
|
|
201
242
|
|
|
202
|
-
Hooks see
|
|
243
|
+
Hooks see Claude Code's own decisions, but not everything: the CLI's own
|
|
203
244
|
local menus (model switch, folder trust) and text on screen (a
|
|
204
245
|
rate-limit message) aren't covered by hooks at all — these events never
|
|
205
246
|
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,19 @@ 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
|
+
# An agent isn't a shell command that finishes in a
|
|
204
|
+
# couple of seconds — it's a real task, and hooks already
|
|
205
|
+
# own telling Telegram when it's actually done or needs
|
|
206
|
+
# something. Capturing a "result" a moment after typing
|
|
207
|
+
# would just catch it mid-thought and fall back to
|
|
208
|
+
# dumping its whole transcript, which isn't a result at
|
|
209
|
+
# all — same reasoning as replying to an agent's own
|
|
210
|
+
# question (type_into_session).
|
|
211
|
+
execute(chat_id, session, command, show_result: !session.agent?)
|
|
212
|
+
end
|
|
192
213
|
end
|
|
193
214
|
end
|
|
194
215
|
|
|
@@ -216,13 +237,125 @@ module AgentsControl
|
|
|
216
237
|
# input: a merged call can fail to send multi-line text at all.
|
|
217
238
|
TYPING_PAUSE = 0.4
|
|
218
239
|
|
|
219
|
-
|
|
240
|
+
# How long to wait after the command lands before reading the
|
|
241
|
+
# screen back. There's no way to know when a command actually
|
|
242
|
+
# finishes without polling for it, so this is a fixed guess
|
|
243
|
+
# tuned for the common case (git status, ls, a quick build
|
|
244
|
+
# check) — long enough for those, short enough not to feel
|
|
245
|
+
# laggy. A slower command just gets caught mid-flight, same as
|
|
246
|
+
# looking over someone's shoulder while it's still running;
|
|
247
|
+
# /screen still shows the up-to-date state a moment later.
|
|
248
|
+
RUN_SETTLE_PAUSE = 1.2
|
|
249
|
+
|
|
250
|
+
def run_result_lines = @config.get("terminal.run_result_lines", 200)
|
|
251
|
+
|
|
252
|
+
def execute(chat_id, session, command, show_result: false)
|
|
220
253
|
backend = @registry.backend_for(session)
|
|
254
|
+
before = show_result ? capture_screen(session, lines: run_result_lines) : nil
|
|
221
255
|
|
|
222
256
|
ok = backend.send_text(session.id, command, newline: false) &&
|
|
223
257
|
sleep(TYPING_PAUSE).then { backend.send_text(session.id, "", newline: true) }
|
|
224
258
|
|
|
225
|
-
say(chat_id,
|
|
259
|
+
return say(chat_id, "Couldn't send.") unless ok
|
|
260
|
+
return say(chat_id, "Sent to #{session.label}.") unless show_result
|
|
261
|
+
|
|
262
|
+
sleep(RUN_SETTLE_PAUSE)
|
|
263
|
+
show_command_result(chat_id, session, since: before, command: command)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Shows what's new since `since` (the screen right before the
|
|
267
|
+
# command was typed) rather than an arbitrary tail slice of the
|
|
268
|
+
# current screen — otherwise a short result could be padded out
|
|
269
|
+
# with whatever the terminal already had on it a moment ago.
|
|
270
|
+
def show_command_result(chat_id, session, since:, command:)
|
|
271
|
+
after = capture_screen(session, lines: run_result_lines)
|
|
272
|
+
text = new_output_since(after, since, command: command)
|
|
273
|
+
log("run result: session=#{session.id} before=#{since.to_s.length}b " \
|
|
274
|
+
"after=#{after.to_s.length}b prefix_match=#{after.to_s.start_with?(since.to_s)} " \
|
|
275
|
+
"command_anchor=#{command.to_s.strip.length >= MIN_COMMAND_ANCHOR && after.to_s.include?(command.to_s.strip)} " \
|
|
276
|
+
"result=#{text.to_s.length}b")
|
|
277
|
+
|
|
278
|
+
if text && !text.empty?
|
|
279
|
+
sent = say_chunked(chat_id, "🖥 #{session_descriptor(session)}\n\n", text, code: true)
|
|
280
|
+
return sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# new_output_since only comes back empty when the capture
|
|
284
|
+
# itself was empty — a genuinely blank screen. The transcript
|
|
285
|
+
# fallback below is keyed by cwd, not by this specific session
|
|
286
|
+
# — several tabs (or VS Code sessions) can sit on the exact
|
|
287
|
+
# same project directory, and it'll pick whichever of them
|
|
288
|
+
# wrote to its transcript most recently. For a plain shell tab
|
|
289
|
+
# that's cross-session noise: a result from a totally
|
|
290
|
+
# different session shown as if it were this one's. Only
|
|
291
|
+
# worth the ambiguity when there's genuinely nothing else to
|
|
292
|
+
# offer — an agent (its own conversation is at least
|
|
293
|
+
# thematically the right session) or a session with no
|
|
294
|
+
# terminal at all (nothing else exists to show).
|
|
295
|
+
return say(chat_id, "The screen is empty.") unless session.agent? || session.terminalless?
|
|
296
|
+
|
|
297
|
+
show_agent_context(chat_id, session)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A one- or two-character reply ("y", "n", "q" mid `git add -p`)
|
|
301
|
+
# turns up constant false matches in ordinary output — not a
|
|
302
|
+
# safe anchor at all. Anything shorter than this falls straight
|
|
303
|
+
# through to the prefix/marker fallback instead.
|
|
304
|
+
MIN_COMMAND_ANCHOR = 3
|
|
305
|
+
|
|
306
|
+
# Returns whatever showed up in `after` beyond `since`.
|
|
307
|
+
#
|
|
308
|
+
# The command itself is the sharpest anchor available: the
|
|
309
|
+
# shell echoes it back verbatim, and unlike the prompt (which
|
|
310
|
+
# looks identical after every command) the command text is
|
|
311
|
+
# specific to this exact invocation. rindex, not index — the
|
|
312
|
+
# *last* occurrence is the one nearest to now, i.e. this send,
|
|
313
|
+
# not some earlier time the same command happened to run. This
|
|
314
|
+
# is also what makes it more reliable than prefix/marker
|
|
315
|
+
# matching when the tab is shared with a human actively typing
|
|
316
|
+
# in it: `since` doesn't have to relate to `after` at all for
|
|
317
|
+
# this to work, since it just locates the command's own echo
|
|
318
|
+
# directly.
|
|
319
|
+
#
|
|
320
|
+
# An exact prefix match is the fallback: unambiguous whenever
|
|
321
|
+
# nothing scrolled out of the capture window between the two
|
|
322
|
+
# snapshots, since `after` is then simply `since` with new
|
|
323
|
+
# content appended and there's nothing to search for at all.
|
|
324
|
+
#
|
|
325
|
+
# Marker search (since's own last two lines, most recent
|
|
326
|
+
# occurrence) is the last resort, for whatever's too short to
|
|
327
|
+
# anchor on and doesn't fit as a literal prefix either.
|
|
328
|
+
#
|
|
329
|
+
# No matter which method resolves it, an anchor is never
|
|
330
|
+
# allowed to produce silence: if it lands on nothing (the
|
|
331
|
+
# reference point itself, unchanged, or a boundary right at the
|
|
332
|
+
# tail), what's actually on screen right now is still real and
|
|
333
|
+
# still worth showing — the whole point of /run is seeing the
|
|
334
|
+
# result, and reporting "no new output" when there's visibly a
|
|
335
|
+
# result sitting right there is a worse failure than
|
|
336
|
+
# occasionally including a line of stale context.
|
|
337
|
+
def new_output_since(after, since, command: nil)
|
|
338
|
+
after_s = after.to_s
|
|
339
|
+
since_s = since.to_s
|
|
340
|
+
|
|
341
|
+
needle = command.to_s.strip
|
|
342
|
+
if needle.length >= MIN_COMMAND_ANCHOR
|
|
343
|
+
index = after_s.rindex(needle)
|
|
344
|
+
return after_s[index..].to_s if index
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
return after_s if since_s.strip.empty?
|
|
348
|
+
|
|
349
|
+
tail = if after_s.start_with?(since_s)
|
|
350
|
+
after_s[since_s.length..]
|
|
351
|
+
else
|
|
352
|
+
marker = since_s.rstrip.lines.last(2).join
|
|
353
|
+
index = marker.strip.empty? ? nil : after_s.rindex(marker)
|
|
354
|
+
index ? after_s[(index + marker.length)..] : nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
tail = tail.to_s.sub(/\A\n+/, "")
|
|
358
|
+
tail.empty? ? after_s : tail
|
|
226
359
|
end
|
|
227
360
|
|
|
228
361
|
def create_tab(chat_id, directory)
|
|
@@ -389,7 +522,7 @@ module AgentsControl
|
|
|
389
522
|
case payload["action"]
|
|
390
523
|
when "focus" then focus_session(chat_id, session)
|
|
391
524
|
when "screen" then show_screen(chat_id, session)
|
|
392
|
-
when "run" then execute(chat_id, session, payload["text"])
|
|
525
|
+
when "run" then execute(chat_id, session, payload["text"], show_result: !session.agent?)
|
|
393
526
|
when "close_confirm" then say(chat_id, "Close #{session.label}?",
|
|
394
527
|
markup: @keyboards.confirm("close", session))
|
|
395
528
|
when "close" then close_session(chat_id, session)
|
|
@@ -406,23 +539,35 @@ module AgentsControl
|
|
|
406
539
|
# just finished lines from the transcript. The transcript stays
|
|
407
540
|
# the fallback for terminalless sessions and for a freshly
|
|
408
541
|
# created tab's blank screen.
|
|
409
|
-
def show_screen(chat_id, session)
|
|
410
|
-
text = capture_screen(session)
|
|
542
|
+
def show_screen(chat_id, session, lines: @config.get("terminal.context_lines", 80))
|
|
543
|
+
text = capture_screen(session, lines: lines)
|
|
411
544
|
if text && !text.empty?
|
|
412
|
-
sent = say_chunked(chat_id, "", text, code: true)
|
|
545
|
+
sent = say_chunked(chat_id, "🖥 #{session_descriptor(session)}\n\n", text, code: true)
|
|
413
546
|
return sent.each { |msg| remember_reply(chat_id, msg, session) }
|
|
414
547
|
end
|
|
415
548
|
|
|
549
|
+
return say(chat_id, "The screen is empty.") unless session.agent? || session.terminalless?
|
|
550
|
+
|
|
416
551
|
show_agent_context(chat_id, session)
|
|
417
552
|
end
|
|
418
553
|
|
|
419
|
-
def capture_screen(session)
|
|
420
|
-
@registry.backend_for(session).capture(session.id,
|
|
421
|
-
lines: @config.get("terminal.context_lines", 80))
|
|
554
|
+
def capture_screen(session, lines: @config.get("terminal.context_lines", 80))
|
|
555
|
+
@registry.backend_for(session).capture(session.id, lines: lines)
|
|
422
556
|
rescue Terminals::Unsupported
|
|
423
557
|
nil
|
|
424
558
|
end
|
|
425
559
|
|
|
560
|
+
# The label alone isn't always enough to tell tabs apart — several
|
|
561
|
+
# can share the same project directory and so the same label. The
|
|
562
|
+
# tty is what actually distinguishes them, and it's the same
|
|
563
|
+
# identifier a terminal's own window/tab chrome would show.
|
|
564
|
+
def session_descriptor(session)
|
|
565
|
+
return session.label if session.terminalless?
|
|
566
|
+
|
|
567
|
+
tty = session.tty.to_s.sub(%r{\A/dev/}, "")
|
|
568
|
+
tty.empty? ? session.label : "#{session.label} · #{tty}"
|
|
569
|
+
end
|
|
570
|
+
|
|
426
571
|
def show_agent_context(chat_id, session)
|
|
427
572
|
transcript = Transcript.for_cwd(session.cwd, root: transcript_root)
|
|
428
573
|
|
|
@@ -476,6 +621,10 @@ module AgentsControl
|
|
|
476
621
|
|
|
477
622
|
@api.send_message(chat_id: chat_id, text: text, reply_markup: markup)
|
|
478
623
|
end
|
|
624
|
+
|
|
625
|
+
def log(message)
|
|
626
|
+
@logger&.puts("[#{Time.now.strftime('%H:%M:%S')}] #{message}")
|
|
627
|
+
end
|
|
479
628
|
end
|
|
480
629
|
end
|
|
481
630
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
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: []
|