agents_control 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cbdfdaec647d99052be4572b93c02e8a1ba8f1169219a85c3abebb1ce1d794b
4
- data.tar.gz: 5d486d2543a88f451eec31e8261ddf8fafb8170446f09f1f13596b07e639f19a
3
+ metadata.gz: cce6dbc39dbec059d87d6f739af51d705b2e11e8b6c11225dd0021c37461f17f
4
+ data.tar.gz: 646165dca05e1718512e7d8d065d2c76568618429999f5eb15c7e58fa9dfab93
5
5
  SHA512:
6
- metadata.gz: e47eca628d62039dd339691b713ad2b123f5fe9205b0394f623fe51806cbb0711b3ee93f5708548855049d6ebf3631776276e8f21b3b977e776392f7bb842be1
7
- data.tar.gz: cdec6b32aade79e9b165c72ac9fc121116b1a90ab1c7e16066737561653c9e5090ab87b593e55da04ca78dbe02099a4903bc626b008fe16170a448a65ab8ecf3
6
+ metadata.gz: d12b481351f8479279aa6867c2206609392d561236cd3680eed567272a496bfff8884ec0f3ebf72e50f1f9c4cac167882886c6087d9c011edda476b6e0229c3e
7
+ data.tar.gz: 907ff99d3b625927e0fba4b79a7aaf4010cd3875b00e6fe5f1d6b7d8e0d2756d34d0e1598cd9af73543cfe67041de7949ee2cf8c6ca080f8ddc885fd35b5b49a
@@ -242,10 +242,16 @@ module AgentsControl
242
242
  raise NetworkError, e.message
243
243
  end
244
244
 
245
+ # SystemCallError as a family rather than a list of Errno
246
+ # constants. The list was a bet on knowing every errno a network
247
+ # stack can raise, and it lost: Errno::EADDRNOTAVAIL — "can't
248
+ # assign requested address", what a VPN reconnecting produces —
249
+ # wasn't on it, so it escaped as itself instead of arriving as
250
+ # Unavailable, and killed the polling thread outright. The
251
+ # daemon stayed up with its port open and simply stopped hearing
252
+ # Telegram, for days, with nothing in the log after the trace.
245
253
  NETWORK_ERRORS = [
246
- Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
247
- Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
248
- SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
254
+ SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
249
255
  ].freeze
250
256
  end
251
257
  end
@@ -12,9 +12,25 @@ module AgentsControl
12
12
  class Bot
13
13
  OFFSET_KEY = "telegram:offset"
14
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
15
+ # Pause after a network failure. The ceiling is low on purpose:
16
+ # nothing tells us the network is back, so the pause is how long
17
+ # the bot stays deaf after a switch between networks — and a
18
+ # minute of that read as "it froze". A closed laptop is asleep
19
+ # and doesn't retry at all, so a short ceiling costs nothing there.
20
+ BACKOFF = [1, 2, 5, 10].freeze
21
+
22
+ # A successful poll leaves a timestamp behind, so something
23
+ # outside this process can tell a bot that is listening from one
24
+ # that stopped hours ago. `polling?` answers that in-process, and
25
+ # doctor runs as a separate command — the daemon's port reports
26
+ # only that it is up, which a deaf daemon is too.
27
+ HEARTBEAT_KEY = "telegram:last_poll"
28
+
29
+ # A healthy poll returns at least every telegram.poll_timeout
30
+ # (30s), and immediately when something arrives. Writing on each
31
+ # one would rewrite the store several times a second under
32
+ # traffic, for a reading nobody needs that finely.
33
+ HEARTBEAT_EVERY = 15
18
34
 
19
35
  def initialize(api:, router:, store:, config:, logger: $stdout)
20
36
  @api = api
@@ -59,6 +75,12 @@ module AgentsControl
59
75
 
60
76
  def stop = @stopped&.push(nil)
61
77
 
78
+ # Whether the Telegram side is actually alive. The daemon holds
79
+ # its port and answers hooks whether or not this thread is
80
+ # running, so without asking there is no way to tell a bot that
81
+ # is listening from one that died quietly hours ago.
82
+ def polling? = @poller&.alive? || false
83
+
62
84
  def running? = @running
63
85
 
64
86
  private
@@ -69,6 +91,7 @@ module AgentsControl
69
91
  while @running
70
92
  begin
71
93
  poll_once
94
+ heartbeat
72
95
  failures = 0
73
96
  rescue Api::Conflict => e
74
97
  # Another process is reading updates — looping further is
@@ -86,10 +109,41 @@ module AgentsControl
86
109
  failures += 1
87
110
  log("API error: #{e.message}")
88
111
  pause(backoff(failures))
112
+ rescue StandardError => e
113
+ # A backstop, not a place to add expected errors above it.
114
+ # Whatever it is, the bot going deaf until someone notices
115
+ # is worse than retrying: the daemon keeps its port and its
116
+ # hooks either way, so a dead poller looks exactly like a
117
+ # healthy one from outside.
118
+ failures += 1
119
+ log("unexpected #{e.class}: #{redacted(e)}, retrying in #{backoff(failures)}s")
120
+ pause(backoff(failures))
89
121
  end
90
122
  end
91
123
  end
92
124
 
125
+ # The message may carry the token, so it is only printed once
126
+ # redaction has actually succeeded — and redaction failing must
127
+ # not take the handler down with it, which is how the backstop
128
+ # above managed to kill the very thread it exists to keep alive.
129
+ def redacted(error)
130
+ @api.redact(error.message)
131
+ rescue StandardError
132
+ "(message withheld — could not redact)"
133
+ end
134
+
135
+ # Best-effort by design: a store that can't be written is not a
136
+ # reason to stop reading Telegram, which is the whole job.
137
+ def heartbeat
138
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ return if @last_heartbeat && now - @last_heartbeat < HEARTBEAT_EVERY
140
+
141
+ @last_heartbeat = now
142
+ @store.put(Time.now.to_i, ttl: 86_400, key: HEARTBEAT_KEY)
143
+ rescue StandardError => e
144
+ log("couldn't record the poll heartbeat: #{e.class}")
145
+ end
146
+
93
147
  def poll_once
94
148
  updates = @api.get_updates(offset: offset, timeout: @config.get("telegram.poll_timeout", 30))
95
149
 
@@ -10,11 +10,19 @@ module AgentsControl
10
10
  # because a button physically can't carry more than 64 bytes, and
11
11
  # the whole content of an action lives on our side anyway.
12
12
  class Channel < Channels::Base
13
- def initialize(api:, store:, config:, registry: nil)
13
+ # Roughly a quarter of an hour in all: long enough to ride out
14
+ # a switch between networks or a VPN reconnecting, short enough
15
+ # that a message doesn't turn up hours after it stopped mattering.
16
+ RESEND_AFTER = [5, 15, 30, 60, 120, 300, 300].freeze
17
+
18
+ def initialize(api:, store:, config:, registry: nil,
19
+ later: ->(&work) { Thread.new(&work) }, sleeper: ->(seconds) { sleep(seconds) })
14
20
  @api = api
15
21
  @store = store
16
22
  @config = config
17
23
  @registry = registry
24
+ @later = later
25
+ @sleeper = sleeper
18
26
  end
19
27
 
20
28
  def ready? = !chats.empty?
@@ -56,15 +64,44 @@ module AgentsControl
56
64
  chunks = [""] if chunks.empty?
57
65
 
58
66
  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
67
+ stuck = send_chunks(chat_id, chunks, 0, markup, event, question_id)
68
+ resend_later(chat_id, chunks, stuck, markup, event, question_id) if stuck
69
+ end
70
+ end
71
+
72
+ # Returns the index of the first chunk the network kept from
73
+ # going out, or nil once there's nothing left to send.
74
+ def send_chunks(chat_id, chunks, from, markup, event, question_id)
75
+ (from...chunks.size).each do |i|
76
+ body = "#{continuation_marker(i, chunks.size)}#{chunks[i]}"
77
+ sent = begin
78
+ send_formatted(chat_id, body, i == chunks.size - 1 ? markup : nil)
79
+ rescue Api::Unavailable
80
+ return i
81
+ end
82
+ remember(chat_id, sent, event, question_id) if event
83
+ end
84
+
85
+ nil
86
+ rescue Api::Error
87
+ # One unreachable chat must not affect the rest, and
88
+ # definitely must not take down the thread holding the agent.
89
+ nil
90
+ end
91
+
92
+ # Off the calling thread: that thread is holding the agent's
93
+ # hook open, and the agent must not sit waiting for the Wi-Fi
94
+ # to come back. Dropping the message instead is how an agent
95
+ # that finished during a network switch went unheard of.
96
+ def resend_later(chat_id, chunks, from, markup, event, question_id)
97
+ @later.call do
98
+ RESEND_AFTER.each do |delay|
99
+ @sleeper.call(delay)
100
+ from = send_chunks(chat_id, chunks, from, markup, event, question_id)
101
+ break unless from
63
102
  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
103
+ rescue StandardError
104
+ nil
68
105
  end
69
106
  end
70
107
 
@@ -98,9 +135,11 @@ module AgentsControl
98
135
  id = sent.is_a?(Hash) ? sent["message_id"] : nil
99
136
  return unless id
100
137
 
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}")
138
+ target = { "session_id" => event.session_id, "question_id" => question_id,
139
+ "cwd" => event.cwd, "label" => event.label }
140
+ @store.put(target, ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
141
+ # Where a message sent without a reply goes — see the Router.
142
+ @store.put(target, ttl: REPLY_TARGET_TTL, key: "last:#{chat_id}")
104
143
  end
105
144
 
106
145
  # A short session tag in the header. Needed so that in a chat
@@ -126,6 +126,10 @@ module AgentsControl
126
126
 
127
127
  return compose_answer(chat_id, text) if composing?(chat_id) && !text.start_with?("/")
128
128
 
129
+ if !text.empty? && !text.start_with?("/") && (target = @store.get(last_target_key(chat_id)))
130
+ return answer_target(chat_id, target, text)
131
+ end
132
+
129
133
  command, argument = text.split(/\s+/, 2)
130
134
 
131
135
  dispatch(chat_id, command.to_s.sub(/@.*\z/, ""), argument.to_s)
@@ -138,6 +142,10 @@ module AgentsControl
138
142
  target = @store.get("reply:#{chat_id}:#{replied['message_id']}")
139
143
  return say(chat_id, "I don't remember which session that message was about.") unless target
140
144
 
145
+ answer_target(chat_id, target, text)
146
+ end
147
+
148
+ def answer_target(chat_id, target, text)
141
149
  question = target["question_id"]
142
150
  return deliver(chat_id, question, Reply.text(text)) if question && @pending&.find(question)
143
151
 
@@ -153,19 +161,44 @@ module AgentsControl
153
161
  # match wins there if one exists (the common case: replying to
154
162
  # an agent's own notification), otherwise any matching tab does
155
163
  # (a plain tab mid-interactive-command has no agent to prefer).
164
+ #
165
+ # Returns the session it typed into, or nil once it has said why not.
156
166
  def type_into_session(chat_id, target, text)
167
+ session = session_for(chat_id, target)
168
+ execute(chat_id, session, text, show_result: !session.agent?) if session
169
+ session
170
+ end
171
+
172
+ def session_for(chat_id, target)
157
173
  exact = @registry.refresh.find(target["session_id"])
158
- return execute(chat_id, exact, text, show_result: !exact.agent?) if exact && !exact.terminalless?
174
+ return exact if exact && !exact.terminalless?
159
175
 
160
- candidates = @registry.sessions.select { |s| s.cwd == target["cwd"] && !s.terminalless? }
161
- agent_matches = candidates.select(&:agent?)
162
- matching = agent_matches.empty? ? candidates : agent_matches
176
+ matching = tabs_for(target["cwd"])
163
177
 
164
- case matching.size
165
- when 0 then say(chat_id, "#{target['label']} isn't waiting anymore, and I couldn't find the tab.")
166
- when 1 then execute(chat_id, matching.first, text, show_result: !matching.first.agent?)
167
- else say(chat_id, "#{target['label']} has several tabs — use /run NUMBER to pick one.")
168
- end
178
+ return matching.first if matching.size == 1
179
+
180
+ say(chat_id, if matching.empty?
181
+ "#{target['label']} isn't waiting anymore, and I couldn't find the tab."
182
+ else
183
+ "#{target['label']} has several tabs — use /run NUMBER to pick one."
184
+ end)
185
+ nil
186
+ end
187
+
188
+ # A hook reports the directory the agent is in now, while the
189
+ # tab and the process only know the one it was started in — so
190
+ # an agent that cd'd into a subfolder matches no tab exactly.
191
+ # The agent in the nearest directory above is that same agent;
192
+ # a plain shell up there is just a neighbour.
193
+ def tabs_for(cwd)
194
+ tabs = @registry.sessions.reject(&:terminalless?)
195
+
196
+ same = tabs.select { |s| s.cwd == cwd }
197
+ return same.select(&:agent?).then { |agents| agents.empty? ? same : agents } unless same.empty?
198
+
199
+ above = tabs.select { |s| s.agent? && !s.cwd.to_s.empty? && cwd.to_s.start_with?(File.join(s.cwd, "")) }
200
+ nearest = above.map { |s| s.cwd.length }.max
201
+ above.select { |s| s.cwd.length == nearest }
169
202
  end
170
203
 
171
204
  # A file from the phone becomes a file on disk, and the agent is
@@ -194,22 +227,50 @@ module AgentsControl
194
227
  # ends up pasted into the wrong project.
195
228
  def deliver_file(chat_id, message, path)
196
229
  caption = message["caption"].to_s.strip
230
+ album = album_key(chat_id, message)
197
231
 
198
232
  if (target = caption[%r{\A/run\s+(\d+)}i, 1])
199
233
  return with_session(chat_id, target) do |session|
200
234
  execute(chat_id, session, with_path(caption.sub(%r{\A/run\s+\d+\s*}i, ""), path),
201
235
  show_result: !session.agent?)
236
+ remember_album(album, session)
202
237
  end
203
238
  end
204
239
 
205
240
  replied = message["reply_to_message"]
206
241
  remembered = replied && @store.get("reply:#{chat_id}:#{replied['message_id']}")
207
- return type_into_session(chat_id, remembered, with_path(caption, path)) if remembered
242
+ return remember_album(album, type_into_session(chat_id, remembered, with_path(caption, path))) if remembered
243
+
244
+ if (session = album_session(album))
245
+ return execute(chat_id, session, with_path("", path))
246
+ end
208
247
 
209
248
  say(chat_id, "Saved:\n\n`#{path}`\n\nReply to a session's message with a file to hand it " \
210
249
  "straight over, or caption it `/run N`.")
211
250
  end
212
251
 
252
+ # Telegram splits an album into one message per file and puts the
253
+ # caption — and the reply — on only one of them, so the rest
254
+ # arrive saying nothing about where they go. They go where the
255
+ # album's caption sent it: sending files together means that.
256
+ def album_key(chat_id, message)
257
+ group = message["media_group_id"]
258
+ group && "album:#{chat_id}:#{group}"
259
+ end
260
+
261
+ # Only an agent gets the rest of the album: a bare path typed
262
+ # into a shell is a command, not a file handed over.
263
+ def remember_album(key, session)
264
+ @store.put(session.id, ttl: 600, key: key) if key && session&.agent?
265
+ session
266
+ end
267
+
268
+ def album_session(key)
269
+ id = key && @store.get(key)
270
+ session = id && @registry.refresh.find(id)
271
+ session if session&.agent? && !session.terminalless?
272
+ end
273
+
213
274
  # Escaped, because a name like "quarterly report.csv" is
214
275
  # entirely ordinary and an unescaped one arrives at the shell as
215
276
  # two arguments. Shellwords leaves a path that needs nothing
@@ -730,10 +791,15 @@ module AgentsControl
730
791
  id = sent.is_a?(Hash) ? sent["message_id"] : nil
731
792
  return unless id
732
793
 
733
- @store.put({ "session_id" => session.id, "cwd" => session.cwd, "label" => session.label },
734
- ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
794
+ target = { "session_id" => session.id, "cwd" => session.cwd, "label" => session.label }
795
+ @store.put(target, ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
796
+ @store.put(target, ttl: REPLY_TARGET_TTL, key: last_target_key(chat_id))
735
797
  end
736
798
 
799
+ # Text sent without a reply goes to the session of the last message
800
+ # the bot sent, so a back-and-forth with one agent needs no replies.
801
+ def last_target_key(chat_id) = "last:#{chat_id}"
802
+
737
803
  def status(chat_id)
738
804
  sessions = @registry.refresh.sessions
739
805
  backends = @registry.available_backends.map(&:name).join(", ")
@@ -105,7 +105,7 @@ module AgentsControl
105
105
  # The daemon comes up immediately: a console without it is just a
106
106
  # window, and the point is to already be connected the moment the tab opens.
107
107
  def start_daemon
108
- @daemon = Daemon.new(config: @config, store: @store, secrets: @secrets, logger: @out)
108
+ @daemon = Daemon.new(config: @config, store: @store, secrets: @secrets, logger: daemon_log)
109
109
 
110
110
  return say("") if @daemon.start
111
111
 
@@ -114,6 +114,16 @@ module AgentsControl
114
114
  say("")
115
115
  end
116
116
 
117
+ # Only puts is ever called on a logger.
118
+ Interjection = Struct.new(:prompt) do
119
+ def puts(text = "") = prompt.interject(text)
120
+ end
121
+
122
+ # With a terminal the prompt owns it between commands, so the
123
+ # daemon's lines go through it; over a pipe there's no raw mode to
124
+ # work around.
125
+ def daemon_log = $stdin.tty? ? Interjection.new(editor) : @out
126
+
117
127
  def loop_commands
118
128
  while (line = prompt)
119
129
  line = line.strip
@@ -18,16 +18,17 @@ module AgentsControl
18
18
  # control the app.
19
19
  NOT_AUTHORISED = "-1743"
20
20
 
21
- def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil)
21
+ def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil, store: nil)
22
22
  @config = config || Config.load
23
23
  @secrets = secrets || Secrets.new
24
24
  @executor = executor
25
25
  @api = api
26
+ @store = store
26
27
  end
27
28
 
28
29
  CHECKS = %i[
29
30
  ruby_check terminal_check automation_check agent_binary_check
30
- token_check chats_check passphrase_check bot_check daemon_check hooks_check
31
+ token_check chats_check passphrase_check bot_check daemon_check polling_check hooks_check
31
32
  anchors_check wake_check
32
33
  ].freeze
33
34
 
@@ -185,6 +186,36 @@ module AgentsControl
185
186
  end
186
187
  end
187
188
 
189
+ # The daemon holds its port and answers hooks whether or not it is
190
+ # still reading Telegram, so "running" and "listening" are separate
191
+ # questions and only the first was ever asked. A poll loop that died
192
+ # on a network error looked exactly like a healthy one — for days,
193
+ # in the case that prompted this.
194
+ # Derived from the configured long-poll timeout rather than fixed:
195
+ # a healthy poll returns at least that often, so a hand-raised
196
+ # telegram.poll_timeout would otherwise make a perfectly good daemon
197
+ # read as deaf. Two windows plus a minute leaves room for a retry
198
+ # and the heartbeat's own throttle.
199
+ def stale_after
200
+ [(@config.get("telegram.poll_timeout", 30).to_i * 2) + 60, 120].max
201
+ end
202
+
203
+ def polling_check
204
+ return nil unless daemon_response(@config.get("hooks.port", Daemon::DEFAULT_PORT)) == :ours
205
+
206
+ last = store.get(Channels::Telegram::Bot::HEARTBEAT_KEY)
207
+ return warn("Telegram", "the daemon hasn't reported a poll yet",
208
+ "if it persists, restart: agents_control stop && agents_control") unless last
209
+
210
+ age = Time.now.to_i - last.to_i
211
+ return ok("Telegram", "polling — last heard #{age}s ago") if age < stale_after
212
+
213
+ fail("Telegram", "the daemon is up but hasn't polled for #{age / 60} min — messages aren't arriving",
214
+ "agents_control stop && agents_control")
215
+ end
216
+
217
+ def store = @store ||= Store.new
218
+
188
219
  def daemon_response(port)
189
220
  socket = Socket.tcp("127.0.0.1", port, connect_timeout: 1)
190
221
  socket.print("POST /ping HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}")
@@ -57,10 +57,17 @@ module AgentsControl
57
57
 
58
58
  class TooLarge < StandardError; end
59
59
 
60
- def initialize(api:, dir: self.class.path, keep_days: 14)
60
+ # Seconds between attempts. A dropped connection on a phone's
61
+ # network or a reconnecting VPN is gone again within seconds, and
62
+ # the alternative is asking someone away from home to send the
63
+ # file again.
64
+ RETRY_AFTER = [1, 3, 5].freeze
65
+
66
+ def initialize(api:, dir: self.class.path, keep_days: 14, sleeper: ->(seconds) { sleep(seconds) })
61
67
  @api = api
62
68
  @dir = dir
63
69
  @keep_days = keep_days
70
+ @sleeper = sleeper
64
71
  end
65
72
 
66
73
  # The local path the file landed at.
@@ -68,17 +75,32 @@ module AgentsControl
68
75
  size = attachment.file_size.to_i
69
76
  raise TooLarge, "#{human(size)} is over the #{human(MAX_BYTES)} Telegram allows a bot to download" if size > MAX_BYTES
70
77
 
71
- remote = @api.file_path_for(attachment.file_id)
72
- prepare
73
- destination = File.join(@dir, name_for(attachment, remote))
78
+ retrying do
79
+ remote = @api.file_path_for(attachment.file_id)
80
+ prepare
81
+ destination = File.join(@dir, name_for(attachment, remote))
74
82
 
75
- download(remote, destination)
76
- prune
77
- destination
83
+ download(remote, destination)
84
+ prune
85
+ destination
86
+ end
78
87
  end
79
88
 
80
89
  private
81
90
 
91
+ def retrying
92
+ attempt = 0
93
+ begin
94
+ yield
95
+ rescue Channels::Telegram::Api::Unavailable
96
+ raise if attempt >= RETRY_AFTER.size
97
+
98
+ @sleeper.call(RETRY_AFTER[attempt])
99
+ attempt += 1
100
+ retry
101
+ end
102
+ end
103
+
82
104
  # Downloaded beside the destination and renamed into place: a
83
105
  # connection that drops half way through otherwise leaves a
84
106
  # truncated file with a name that promises a whole one, and the
@@ -27,7 +27,12 @@ module AgentsControl
27
27
  # i.e. what the user is currently interacting with.
28
28
  def foreground? = stat.to_s.include?("+")
29
29
  def name = File.basename(command.to_s)
30
- def agent = AGENT_BINARIES[name]
30
+
31
+ # Claude Code retitles its own process to `Claude` partway through
32
+ # a long session, so an exact match loses the tab. The desktop app
33
+ # is also called `Claude`, but it never has a terminal — that's
34
+ # what keeps it from passing for an agent here.
35
+ def agent = AGENT_BINARIES[name] || (terminal? ? AGENT_BINARIES[name.downcase] : nil)
31
36
  def agent? = !agent.nil?
32
37
  def terminal? = tty != NO_TTY
33
38
  end
@@ -37,10 +42,13 @@ module AgentsControl
37
42
  end
38
43
 
39
44
  # Re-reads the process tree. The result is cached on the object — one
40
- # probe per registry pass.
45
+ # probe per registry pass. The per-tab index is derived from it, so it
46
+ # has to be dropped here too: a long-lived daemon would otherwise keep
47
+ # answering from the first snapshot it ever took.
41
48
  def refresh
42
49
  result = @executor.run(Which.find!("ps"), "-A", "-o", "tty=,pid=,stat=,comm=")
43
50
  @processes = result.success? ? parse(result.stdout) : []
51
+ @by_tty = nil
44
52
  self
45
53
  end
46
54
 
@@ -19,6 +19,23 @@ module AgentsControl
19
19
  @completer = completer || ->(_word) { [] }
20
20
  @describer = describer || ->(_item) { nil }
21
21
  @history = []
22
+ # Reentrant: interject redraws the line, and so does every keystroke.
23
+ @lock = Monitor.new
24
+ end
25
+
26
+ # A line from another thread — the daemon logs while someone types.
27
+ # Raw mode turns off the terminal's own "\n" → "\r\n", so printed
28
+ # as-is every line started where the previous one ended and landed
29
+ # on top of the line being typed. It goes above that line instead,
30
+ # and the line is drawn again underneath.
31
+ def interject(text)
32
+ @lock.synchronize do
33
+ return @out.puts(text) unless @reading
34
+
35
+ erase_drawn
36
+ @out.print("#{text.to_s.chomp.gsub("\n", "\r\n")}\r\n")
37
+ render
38
+ end
22
39
  end
23
40
 
24
41
  # Returns the entered line, or nil if input closed (Ctrl-D).
@@ -32,12 +49,25 @@ module AgentsControl
32
49
  @history_at = @history.size
33
50
  @result = nil
34
51
 
35
- render
36
- @in.raw { loop { break if handle(read_key) == :done } }
52
+ @lock.synchronize do
53
+ render
54
+ @reading = true
55
+ end
56
+ # The key is read outside the lock: waiting for one must not hold
57
+ # back a log line.
58
+ @in.raw do
59
+ loop do
60
+ key = read_key
61
+ break if @lock.synchronize { handle(key) } == :done
62
+ end
63
+ end
37
64
 
38
65
  @result
39
66
  ensure
40
- finish
67
+ @lock.synchronize do
68
+ @reading = false
69
+ finish
70
+ end
41
71
  end
42
72
 
43
73
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentsControl
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "monitor"
4
5
  require "shellwords"
5
6
 
6
7
  # The base error is declared before the other files: error classes
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sapar Kurmanov