agents_control 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/README.md +282 -0
  4. data/exe/agents_control +6 -0
  5. data/lib/agents_control/agents/base.rb +52 -0
  6. data/lib/agents_control/agents/claude_code.rb +243 -0
  7. data/lib/agents_control/anchors/scheduler.rb +161 -0
  8. data/lib/agents_control/channels/base.rb +27 -0
  9. data/lib/agents_control/channels/telegram/api.rb +179 -0
  10. data/lib/agents_control/channels/telegram/bot.rb +146 -0
  11. data/lib/agents_control/channels/telegram/channel.rb +251 -0
  12. data/lib/agents_control/channels/telegram/chunker.rb +64 -0
  13. data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
  14. data/lib/agents_control/channels/telegram/markdown.rb +59 -0
  15. data/lib/agents_control/channels/telegram/router.rb +482 -0
  16. data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
  17. data/lib/agents_control/cli.rb +347 -0
  18. data/lib/agents_control/config.rb +178 -0
  19. data/lib/agents_control/console.rb +320 -0
  20. data/lib/agents_control/daemon.rb +251 -0
  21. data/lib/agents_control/dispatcher.rb +163 -0
  22. data/lib/agents_control/doctor.rb +234 -0
  23. data/lib/agents_control/event.rb +111 -0
  24. data/lib/agents_control/executor.rb +83 -0
  25. data/lib/agents_control/hooks/server.rb +197 -0
  26. data/lib/agents_control/keyboard.rb +90 -0
  27. data/lib/agents_control/menu.rb +100 -0
  28. data/lib/agents_control/pending.rb +70 -0
  29. data/lib/agents_control/process_probe.rb +136 -0
  30. data/lib/agents_control/prompt.rb +227 -0
  31. data/lib/agents_control/rate_limit_watcher.rb +202 -0
  32. data/lib/agents_control/registry.rb +115 -0
  33. data/lib/agents_control/reply.rb +41 -0
  34. data/lib/agents_control/screen_watcher.rb +158 -0
  35. data/lib/agents_control/secrets.rb +256 -0
  36. data/lib/agents_control/service.rb +165 -0
  37. data/lib/agents_control/session.rb +66 -0
  38. data/lib/agents_control/store.rb +132 -0
  39. data/lib/agents_control/terminals/base.rb +76 -0
  40. data/lib/agents_control/terminals/iterm2.rb +167 -0
  41. data/lib/agents_control/terminals/null.rb +27 -0
  42. data/lib/agents_control/terminals/tmux.rb +106 -0
  43. data/lib/agents_control/transcript.rb +123 -0
  44. data/lib/agents_control/version.rb +5 -0
  45. data/lib/agents_control/which.rb +59 -0
  46. data/lib/agents_control.rb +52 -0
  47. metadata +102 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module AgentsControl
8
+ # State that survives a daemon restart.
9
+ #
10
+ # Needed for two reasons at once:
11
+ #
12
+ # 1. Telegram limits button `callback_data` to 64 bytes. A session
13
+ # identifier won't fit there, let alone an action's text — a button
14
+ # carries only a short key, and the action itself lives here.
15
+ # 2. While the owner is out, the daemon can crash and come back up.
16
+ # Pending questions have to survive that, or the agent is left
17
+ # waiting forever.
18
+ #
19
+ # There are dozens of records here, not millions, and only one process
20
+ # writes them — so an atomically-replaced file and a mutex, not a database.
21
+ class Store
22
+ DEFAULT_TTL = 3600
23
+
24
+ def self.path
25
+ base = ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state")
26
+ File.join(base, "agents_control", "store.json")
27
+ end
28
+
29
+ attr_reader :path
30
+
31
+ def initialize(path: self.class.path, clock: -> { Time.now.to_i })
32
+ @path = path
33
+ @clock = clock
34
+ @mutex = Mutex.new
35
+ end
36
+
37
+ # Short random key for a button. Eight base36 characters is ~41 bits,
38
+ # which is plenty: keys live minutes and are checked one at a time.
39
+ def put(value, ttl: DEFAULT_TTL, key: nil)
40
+ key ||= SecureRandom.alphanumeric(8).downcase
41
+
42
+ write do |data|
43
+ data[key] = { "value" => value, "expires_at" => now + ttl }
44
+ end
45
+
46
+ key
47
+ end
48
+
49
+ def get(key)
50
+ entry = read[key]
51
+ return nil if entry.nil? || expired?(entry)
52
+
53
+ entry["value"]
54
+ end
55
+
56
+ # Take the value and delete it in the same step.
57
+ #
58
+ # This is exactly how a button press is handled: Telegram redelivers
59
+ # the callback if the connection drops, and a finger can tap twice.
60
+ # The first call gets the value, every call after gets nil, and the
61
+ # action never runs a second time. The check and the delete happen
62
+ # under one mutex, or two simultaneous presses could both see the value.
63
+ # The value has to come back as the block's result, not via `return`:
64
+ # an early return would unwind past the file write, the delete would
65
+ # never reach disk, and the button would fire again after a restart.
66
+ def take(key)
67
+ write do |data|
68
+ entry = data.delete(key)
69
+
70
+ entry.nil? || expired?(entry) ? nil : entry["value"]
71
+ end
72
+ end
73
+
74
+ def delete(key)
75
+ write { |data| data.delete(key) }
76
+ end
77
+
78
+ # All live records — for recovering state after a restart.
79
+ def all
80
+ read.reject { |_key, entry| expired?(entry) }
81
+ .transform_values { |entry| entry["value"] }
82
+ end
83
+
84
+ def prune
85
+ write { |data| data.reject! { |_key, entry| expired?(entry) } }
86
+ end
87
+
88
+ private
89
+
90
+ def now = @clock.call
91
+
92
+ def expired?(entry) = entry["expires_at"].to_i <= now
93
+
94
+ def read
95
+ @mutex.owned? ? load_file : @mutex.synchronize { load_file }
96
+ end
97
+
98
+ def write
99
+ @mutex.synchronize do
100
+ data = load_file
101
+ result = yield(data)
102
+ save_file(data)
103
+ result
104
+ end
105
+ end
106
+
107
+ def load_file
108
+ return {} unless File.exist?(path)
109
+
110
+ parsed = JSON.parse(File.read(path))
111
+ parsed.is_a?(Hash) ? parsed : {}
112
+ rescue JSON::ParserError, Errno::ENOENT
113
+ # A corrupt file must not keep the daemon from starting. Losing
114
+ # pending questions is unfortunate, but failing to start at all is
115
+ # worse — the agent would be left blocked with no way to answer.
116
+ {}
117
+ end
118
+
119
+ # Replace via rename: a process reading the file at that moment sees
120
+ # either the whole old version or the whole new one, never half of either.
121
+ def save_file(data)
122
+ FileUtils.mkdir_p(File.dirname(path))
123
+ temporary = "#{path}.#{Process.pid}.tmp"
124
+
125
+ File.write(temporary, JSON.generate(data))
126
+ File.chmod(0o600, temporary)
127
+ File.rename(temporary, path)
128
+ ensure
129
+ FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Terminals
5
+ # An operation this backend doesn't have. For example, a tabless
6
+ # session has nothing to create or read from the screen.
7
+ class Unsupported < StandardError; end
8
+
9
+ # The terminal backend contract.
10
+ #
11
+ # There are three implementations right now (iTerm2, tmux, null), and
12
+ # they're chosen not globally but per session: the same machine can
13
+ # hold bare iTerm2 tabs, tmux panes, and VS Code sessions with no
14
+ # terminal at all, all at once.
15
+ #
16
+ # Every method must be safe when the backend is unavailable: return an
17
+ # empty result or false, never raise out to the caller or hang it.
18
+ class Base
19
+ def initialize(executor: Executor.new, probe: nil)
20
+ @executor = executor
21
+ @probe = probe
22
+ end
23
+
24
+ # The backend's symbolic name — ends up in Session#backend.
25
+ def name = raise(NotImplementedError)
26
+
27
+ # Whether the backend can answer right now.
28
+ # Must have no side effects: in particular, must not launch an app
29
+ # the user hasn't already opened.
30
+ def available? = raise(NotImplementedError)
31
+
32
+ # [Session] — all of this backend's tabs/panes.
33
+ def sessions = raise(NotImplementedError)
34
+
35
+ def create_tab(cwd: nil, command: nil) = raise(Unsupported, "#{name}: creating a tab is not supported")
36
+
37
+ def send_text(_id, _text, newline: true) = raise(Unsupported, "#{name}: sending input is not supported")
38
+
39
+ def capture(_id, lines: 200) = raise(Unsupported, "#{name}: reading the screen is not supported")
40
+
41
+ def focus(_id) = raise(Unsupported, "#{name}: focusing is not supported")
42
+
43
+ def close(_id) = raise(Unsupported, "#{name}: closing is not supported")
44
+
45
+ private
46
+
47
+ attr_reader :executor
48
+
49
+ # The registry hands over its own probe, so the process tree is
50
+ # read once per pass instead of separately by each backend.
51
+ def probe
52
+ @probe ||= ProcessProbe.new(executor: executor)
53
+ end
54
+
55
+ # Field and record separators.
56
+ #
57
+ # Plain `|` or a tab won't do: a tab title holds arbitrary text
58
+ # that the user or the agent writes however they like — anything
59
+ # can show up there, including newlines. The US and RS control
60
+ # characters never appear in that text.
61
+ FIELD = "\x1F"
62
+ RECORD = "\x1E"
63
+
64
+ def parse_records(output, fields)
65
+ output.split(RECORD).filter_map do |record|
66
+ next if record.strip.empty?
67
+
68
+ values = record.split(FIELD, -1)
69
+ next if values.size < fields.size
70
+
71
+ fields.zip(values).to_h
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Terminals
5
+ # iTerm2 via AppleScript.
6
+ #
7
+ # Scripts are passed to osascript over stdin, and parameters via
8
+ # `on run argv`. Values are never interpolated into the script text:
9
+ # identifiers, and especially arbitrary user commands, would
10
+ # otherwise turn into an AppleScript injection.
11
+ #
12
+ # A full scan of sessions via AppleScript is expensive, so the list
13
+ # is built on demand rather than polled on a timer. Agent state
14
+ # comes from hooks, not from here.
15
+ class ITerm2 < Base
16
+ # The session identifier matches the ITERM_SESSION_ID variable that
17
+ # iTerm2 puts in a tab's environment. A hook uses it to tie an
18
+ # agent session to a specific tab.
19
+ LIST = <<~APPLESCRIPT
20
+ on run argv
21
+ set fs to (character id 31)
22
+ set rs to (character id 30)
23
+ set out to ""
24
+ tell application "iTerm2"
25
+ repeat with w in windows
26
+ repeat with t in tabs of w
27
+ repeat with s in sessions of t
28
+ set out to out & (id of s) & fs & (tty of s) & fs ¬
29
+ & ((is processing of s) as text) & fs ¬
30
+ & ((is at shell prompt of s) as text) & fs ¬
31
+ & (variable s named "path") & fs ¬
32
+ & (name of s) & rs
33
+ end repeat
34
+ end repeat
35
+ end repeat
36
+ end tell
37
+ return out
38
+ end run
39
+ APPLESCRIPT
40
+
41
+ # Shared wrapper: find a session by id and do something with it.
42
+ # %s is substituted right here in the code, never from user input.
43
+ FIND_AND = <<~APPLESCRIPT
44
+ on run argv
45
+ set target_id to item 1 of argv
46
+ tell application "iTerm2"
47
+ repeat with w in windows
48
+ repeat with t in tabs of w
49
+ repeat with s in sessions of t
50
+ if (id of s) is target_id then
51
+ %s
52
+ return "ok"
53
+ end if
54
+ end repeat
55
+ end repeat
56
+ end repeat
57
+ end tell
58
+ return "missing"
59
+ end run
60
+ APPLESCRIPT
61
+
62
+ CREATE_TAB = <<~APPLESCRIPT
63
+ on run argv
64
+ set target_dir to item 1 of argv
65
+ set target_cmd to item 2 of argv
66
+ tell application "iTerm2"
67
+ if (count of windows) is 0 then
68
+ set w to (create window with default profile)
69
+ else
70
+ set w to current window
71
+ tell w to create tab with default profile
72
+ end if
73
+ set s to current session of current tab of w
74
+ if target_dir is not "" then
75
+ tell s to write text ("cd " & quoted form of target_dir)
76
+ end if
77
+ if target_cmd is not "" then
78
+ tell s to write text target_cmd
79
+ end if
80
+ return (id of s)
81
+ end tell
82
+ end run
83
+ APPLESCRIPT
84
+
85
+ FIELDS = %i[id tty processing at_shell_prompt cwd title].freeze
86
+
87
+ def name = :iterm2
88
+
89
+ # Checked against the process tree, not via AppleScript: talking to
90
+ # the app over AppleScript launches it if it's closed, and an
91
+ # availability check has no business opening a terminal on the user.
92
+ #
93
+ # pgrep won't do here: on macOS `-x` matches against the full path,
94
+ # and in a restricted environment it misses processes that ps sees fine.
95
+ def available? = probe.running?("iTerm2")
96
+
97
+ def sessions
98
+ result = osascript(LIST)
99
+ return [] unless result.success?
100
+
101
+ parse_records(result.stdout, FIELDS).map do |record|
102
+ Session.new(
103
+ id: record[:id],
104
+ backend: name,
105
+ tty: record[:tty],
106
+ title: record[:title].to_s.strip,
107
+ cwd: record[:cwd].to_s.strip,
108
+ processing: record[:processing] == "true",
109
+ at_shell_prompt: record[:at_shell_prompt] == "true"
110
+ )
111
+ end
112
+ end
113
+
114
+ def create_tab(cwd: nil, command: nil)
115
+ result = osascript(CREATE_TAB, cwd.to_s, command.to_s)
116
+ result.success? ? result.stdout.strip : nil
117
+ end
118
+
119
+ def send_text(id, text, newline: true)
120
+ # The text goes in as argv's second argument, not into the script body.
121
+ script = format(FIND_AND, %(tell s to write text (item 2 of argv) newline #{newline}))
122
+ act(script, id, text)
123
+ end
124
+
125
+ def capture(id, lines: 200)
126
+ script = format(FIND_AND, "return contents of s")
127
+ result = osascript(script, id)
128
+ return "" unless result.success?
129
+
130
+ # AppleScript only hands back the visible area — there's no
131
+ # scrollback here; for history, go to tmux or the agent's transcript.
132
+ #
133
+ # `contents of s` is the whole visible screen, including blank
134
+ # filler lines below the text when it doesn't reach the bottom of
135
+ # the pane. rstrip removes that tail before the .last(lines) cut —
136
+ # otherwise, on a pane taller than its content, the cut would
137
+ # grab only blank lines.
138
+ text = result.stdout.rstrip
139
+ text.empty? ? "" : text.lines.last(lines).join.rstrip
140
+ end
141
+
142
+ def focus(id)
143
+ act(format(FIND_AND, "tell t to select\n tell w to select"), id)
144
+ end
145
+
146
+ def close(id)
147
+ act(format(FIND_AND, "tell s to close"), id)
148
+ end
149
+
150
+ private
151
+
152
+ def act(script, *args)
153
+ result = osascript(script, *args)
154
+ result.success? && !result.stdout.include?("missing")
155
+ end
156
+
157
+ def osascript(script, *args)
158
+ binary = Which.find("osascript")
159
+ return Executor::Result.new(stdout: "", stderr: "osascript not found", status: 127) unless binary
160
+
161
+ # Scanning fifteen sessions takes ~0.9s; give it headroom for a
162
+ # slow AppleEvent, but not forever — iTerm2 itself can hang.
163
+ executor.run(binary, "-", *args, stdin: script, timeout: 20)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Terminals
5
+ # Backend for sessions that have no terminal at all.
6
+ #
7
+ # This isn't a stub for an exotic edge case: an agent launched from
8
+ # the VS Code extension sits on tty `??` — it has neither a tab nor a
9
+ # pty, and such sessions are often the majority of active ones, not a
10
+ # rare exception.
11
+ #
12
+ # A session like this shows up in the list and can be worked with
13
+ # through hooks — answering questions, reading the transcript. What's
14
+ # off-limits is anything that needs a terminal: sending text, reading
15
+ # the screen, creating a tab.
16
+ class Null < Base
17
+ def name = :none
18
+
19
+ def available? = true
20
+
21
+ # This backend doesn't enumerate its own sessions: they're
22
+ # supplied by ProcessProbe (terminalless agent processes) and the
23
+ # hook registry.
24
+ def sessions = []
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Terminals
5
+ # tmux.
6
+ #
7
+ # The only backend that works on both macOS and Linux, and also the
8
+ # fastest one: a full pane list comes back in 0.008s versus 0.9s for
9
+ # iTerm2 — a hundred-plus-fold difference. It's also the only one with
10
+ # scrollback and the only one that survives an ssh disconnect.
11
+ #
12
+ # Inside iTerm2 it runs as `tmux -CC`: tmux windows become native
13
+ # tabs, and both backends operate on the same machine at once.
14
+ class Tmux < Base
15
+ # %w doesn't interpolate — the literal tmux substitutions stay intact inside.
16
+ FORMAT_FIELDS = %w[
17
+ #{pane_id}
18
+ #{pane_tty}
19
+ #{pane_current_command}
20
+ #{pane_current_path}
21
+ #{session_name}
22
+ #{window_name}
23
+ ].freeze
24
+
25
+ FIELDS = %i[id tty command cwd session window].freeze
26
+
27
+ # Shells under which a pane counts as "at a prompt" rather than busy.
28
+ SHELLS = %w[zsh bash sh fish dash ksh tcsh].freeze
29
+
30
+ def name = :tmux
31
+
32
+ # Just having the binary is enough: the server might not be
33
+ # running yet, but we can still create a window in it.
34
+ def available? = !binary.nil?
35
+
36
+ def sessions
37
+ return [] unless available?
38
+
39
+ result = run("list-panes", "-a", "-F", format_string)
40
+ # No server isn't an error, it just means there are no panes yet.
41
+ return [] unless result.success?
42
+
43
+ parse_records(result.stdout, FIELDS).map do |record|
44
+ Session.new(
45
+ id: record[:id],
46
+ backend: name,
47
+ tty: record[:tty],
48
+ title: [record[:session], record[:window]].compact.join(":"),
49
+ cwd: record[:cwd],
50
+ # tmux has no equivalent of `is processing`, so this is nil —
51
+ # "unknown", not "no". ProcessProbe determines busy-ness.
52
+ processing: nil,
53
+ at_shell_prompt: SHELLS.include?(record[:command].to_s),
54
+ # tmux already knows what's running in the pane — this same
55
+ # list-panes call hands back the process name for free, with
56
+ # no trip through ProcessProbe needed.
57
+ foreground_command: record[:command]
58
+ )
59
+ end
60
+ end
61
+
62
+ def create_tab(cwd: nil, command: nil)
63
+ args = ["new-window", "-P", "-F", "#{'#'}{pane_id}"]
64
+ args += ["-c", cwd] if cwd && !cwd.empty?
65
+ args << command if command && !command.empty?
66
+
67
+ result = run(*args)
68
+ result.success? ? result.stdout.strip : nil
69
+ end
70
+
71
+ def send_text(id, text, newline: true)
72
+ args = ["send-keys", "-t", id, "--", text]
73
+ args << "Enter" if newline
74
+ run(*args).success?
75
+ end
76
+
77
+ # This is exactly what tmux is for: history, not just the visible screen.
78
+ def capture(id, lines: 200)
79
+ result = run("capture-pane", "-p", "-t", id, "-S", "-#{lines}")
80
+ result.success? ? result.stdout.rstrip : ""
81
+ end
82
+
83
+ def focus(id)
84
+ run("select-window", "-t", id).success? && run("select-pane", "-t", id).success?
85
+ end
86
+
87
+ def close(id) = run("kill-pane", "-t", id).success?
88
+
89
+ private
90
+
91
+ def format_string = FORMAT_FIELDS.join(FIELD) + RECORD
92
+
93
+ def run(*args)
94
+ return Executor::Result.new(stdout: "", stderr: "tmux not found", status: 127) unless binary
95
+
96
+ executor.run(binary, *args)
97
+ end
98
+
99
+ def binary
100
+ # false means "looked and didn't find it", so we don't search again on every call.
101
+ @binary = Which.find("tmux") || false if @binary.nil?
102
+ @binary || nil
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentsControl
6
+ # The tail of a conversation with an agent.
7
+ #
8
+ # Read from the JSONL file whose path the agent sends with every hook.
9
+ # This beats scraping text off the screen for three reasons: it works
10
+ # the same for a terminal tab and for a VS Code session, which has no
11
+ # screen at all; it gives structure instead of ANSI noise; and it isn't
12
+ # limited to what fit in the visible area.
13
+ class Transcript
14
+ # The file grows without bound, and only the end matters. Read from
15
+ # the end in blocks, so a multi-megabyte history never has to be
16
+ # pulled fully into memory.
17
+ TAIL_BYTES = 256 * 1024
18
+
19
+ PROJECTS = File.expand_path("~/.claude/projects")
20
+
21
+ class << self
22
+ # The transcript of a tab the hooks never sent anything about.
23
+ #
24
+ # Claude Code lays out histories in directories named after the
25
+ # working path, with `/` and `_` replaced by `-`. If that scheme
26
+ # ever changes, this method just returns an empty transcript
27
+ # instead of breaking its caller.
28
+ def for_cwd(cwd, root: PROJECTS)
29
+ return new(nil) if cwd.to_s.empty?
30
+
31
+ directory = File.join(root, slug(cwd))
32
+ new(newest_in(directory))
33
+ end
34
+
35
+ def slug(cwd) = cwd.to_s.tr("/_", "--")
36
+
37
+ private
38
+
39
+ # One directory maps to many sessions — take the most recently modified.
40
+ def newest_in(directory)
41
+ Dir.glob(File.join(directory, "*.jsonl")).max_by { |path| File.mtime(path) }
42
+ end
43
+ end
44
+
45
+ def initialize(path)
46
+ @path = path
47
+ end
48
+
49
+ def exists? = @path && File.exist?(@path)
50
+
51
+ # The last N messages as [{role:, text:}].
52
+ def last(count = 6)
53
+ return [] unless exists?
54
+
55
+ messages = parse(tail)
56
+ messages.last(count)
57
+ end
58
+
59
+ # Ready-to-send text, in full — splitting across multiple Telegram
60
+ # messages, if needed, is the caller's job (Router#say_chunked).
61
+ def render(count = 6)
62
+ entries = last(count)
63
+ return "The conversation is empty." if entries.empty?
64
+
65
+ entries.map { |entry| "#{prefix(entry[:role])} #{entry[:text]}" }.join("\n\n")
66
+ end
67
+
68
+ private
69
+
70
+ def tail
71
+ size = File.size(@path)
72
+ offset = [size - TAIL_BYTES, 0].max
73
+
74
+ File.open(@path, "rb") do |file|
75
+ file.seek(offset)
76
+ # The first line after the offset is almost certainly cut off — discard it.
77
+ file.gets if offset.positive?
78
+ file.read.to_s
79
+ end
80
+ end
81
+
82
+ def parse(raw)
83
+ raw.each_line.filter_map do |line|
84
+ entry = JSON.parse(line)
85
+ text = extract(entry)
86
+ next if text.nil? || text.empty?
87
+
88
+ { role: entry["type"] || entry.dig("message", "role"), text: text }
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+ end
93
+
94
+ # Content is either a string or an array of blocks; tool calls are
95
+ # shown by name, not as the full argument JSON — a notification needs
96
+ # to convey what's happening, not reproduce the call.
97
+ def extract(entry)
98
+ content = entry.dig("message", "content")
99
+
100
+ case content
101
+ when String then content
102
+ when Array then extract_blocks(content)
103
+ end
104
+ end
105
+
106
+ def extract_blocks(blocks)
107
+ blocks.filter_map do |block|
108
+ case block["type"]
109
+ when "text" then block["text"]
110
+ when "tool_use" then "→ #{block['name']}"
111
+ end
112
+ end.join("\n").strip
113
+ end
114
+
115
+ def prefix(role)
116
+ case role.to_s
117
+ when "user" then "🙋"
118
+ when "assistant" then "🤖"
119
+ else "·"
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Finds executables by absolute path rather than via PATH.
5
+ #
6
+ # The daemon is launched by launchd, not an interactive shell, and PATH
7
+ # is different there: Ruby version-manager shims may be missing, and an
8
+ # Intel Homebrew install on Apple Silicon without Rosetta doesn't run at
9
+ # all. So PATH is checked last, not first.
10
+ module Which
11
+ # Directories in order of trust: version managers and ARM Homebrew
12
+ # first, then system paths, and only then whatever PATH turns up.
13
+ SEARCH_DIRS = [
14
+ "~/.asdf/shims",
15
+ "~/.rbenv/shims",
16
+ "/opt/homebrew/bin",
17
+ "/opt/homebrew/sbin",
18
+ "/usr/local/bin",
19
+ "/usr/bin",
20
+ "/bin",
21
+ "/usr/sbin",
22
+ "/sbin"
23
+ ].freeze
24
+
25
+ module_function
26
+
27
+ # Absolute path to the binary, or nil.
28
+ def find(name, extra_dirs: [])
29
+ dirs = extra_dirs + SEARCH_DIRS
30
+ from_dirs(name, dirs) || from_path(name)
31
+ end
32
+
33
+ # Like find, but with a clear error instead of nil — for places where
34
+ # a missing binary means there's no point continuing.
35
+ def find!(name, extra_dirs: [])
36
+ find(name, extra_dirs: extra_dirs) ||
37
+ raise(NotFoundError, "executable not found: #{name.inspect}")
38
+ end
39
+
40
+ def from_dirs(name, dirs)
41
+ dirs.lazy
42
+ .map { |dir| File.join(File.expand_path(dir), name) }
43
+ .find { |path| executable?(path) }
44
+ end
45
+
46
+ def from_path(name)
47
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).lazy
48
+ .reject(&:empty?)
49
+ .map { |dir| File.join(dir, name) }
50
+ .find { |path| executable?(path) }
51
+ end
52
+
53
+ def executable?(path)
54
+ File.file?(path) && File.executable?(path)
55
+ end
56
+
57
+ class NotFoundError < StandardError; end
58
+ end
59
+ end