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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7396050fbeeb69af3c8fd481608facce44bce68072dc043f03fa0bc34138de5b
4
- data.tar.gz: '05597674bc05d4bb1142e1295c264f737caf7be5afee724616ec4e0673db3870'
3
+ metadata.gz: 5ca83918e01b4dc98c7b72f17da5cd31d859d863d61614f7eee74dd01e62ecb2
4
+ data.tar.gz: d50f30441533aded38f243800dda29a884fd01fda135506dee034115eeb63e7a
5
5
  SHA512:
6
- metadata.gz: efe615074ece6b5898eecaaa66fdc882c46d2b6a74c715b18768b347ab3af3ef180774930dc46d5648461a006b96059bd265c67f676e45ed2eadcc0017bc6ee2
7
- data.tar.gz: 6137a2654888b576860d1a8463e5aff520ace959c687974e6bd20e3425ba40f98673391517c30dc7d1c54a58bbbbad070aa2253891d6553458f774354c9c6543
6
+ metadata.gz: 9dd870ff93dd579dd1bbbc7cffd7e9f62d7a5d25ba79d2f69adb6018ac1ae8061614b5e1d09db5bc817d65f8f6a33fc7557b8a6a035245d640e92373bcdc598d
7
+ data.tar.gz: 71c5a463b794b2196fa3023a0a703ab6cd02da596f2b1f003ea1365db96f9a67e8d3ab3e1ddc51bdd470d76217395907565a2d53ef65dccf6fc0b411c04dee0a
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # agents_control
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/agents_control)](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 the agent's hooks, which work
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), but for now only for Claude Code.
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: the agent
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
- ## Usage
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
- While you're at the keyboard, intercepting agent questions is
133
- counterproductive: you'll answer in the terminal faster than you can
134
- reach for your phone, and a blocked hook keeps the dialog from ever
135
- appearing on screen. So there are two modes:
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; the agent stands by until you answer or time runs out.
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
- produce an agent that approves itself everything while nobody's
163
- watching. A question that offers a choice ("rewrite it or leave it?")
164
- is never answered automatically, even if it contains the word "continue."
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
- the agent prints a warning about an unreachable address in every
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 the agent's own decisions, but not everything: the CLI's own
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: ACTION_TTL, key: index_key(chat_id))
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. Matching by cwd is the fallback for
99
- # when that tab has already closed.
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) if exact&.agent? && !exact.terminalless?
109
+ return execute(chat_id, exact, text, show_result: !exact.agent?) if exact && !exact.terminalless?
103
110
 
104
- matching = @registry.agents.select { |s| s.cwd == target["cwd"] && !s.terminalless? }
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) ? confirm_remote(chat_id, session, command) : execute(chat_id, session, command)
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
- def execute(chat_id, session, command)
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, ok ? "Sent to #{session.label}." : "Couldn't send.")
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentsControl
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
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.0
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 a terminal AI agent stops and waits
28
- on a human, and relays them to Telegram — with the question's text and
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: []