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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +282 -0
- data/exe/agents_control +6 -0
- data/lib/agents_control/agents/base.rb +52 -0
- data/lib/agents_control/agents/claude_code.rb +243 -0
- data/lib/agents_control/anchors/scheduler.rb +161 -0
- data/lib/agents_control/channels/base.rb +27 -0
- data/lib/agents_control/channels/telegram/api.rb +179 -0
- data/lib/agents_control/channels/telegram/bot.rb +146 -0
- data/lib/agents_control/channels/telegram/channel.rb +251 -0
- data/lib/agents_control/channels/telegram/chunker.rb +64 -0
- data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
- data/lib/agents_control/channels/telegram/markdown.rb +59 -0
- data/lib/agents_control/channels/telegram/router.rb +482 -0
- data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
- data/lib/agents_control/cli.rb +347 -0
- data/lib/agents_control/config.rb +178 -0
- data/lib/agents_control/console.rb +320 -0
- data/lib/agents_control/daemon.rb +251 -0
- data/lib/agents_control/dispatcher.rb +163 -0
- data/lib/agents_control/doctor.rb +234 -0
- data/lib/agents_control/event.rb +111 -0
- data/lib/agents_control/executor.rb +83 -0
- data/lib/agents_control/hooks/server.rb +197 -0
- data/lib/agents_control/keyboard.rb +90 -0
- data/lib/agents_control/menu.rb +100 -0
- data/lib/agents_control/pending.rb +70 -0
- data/lib/agents_control/process_probe.rb +136 -0
- data/lib/agents_control/prompt.rb +227 -0
- data/lib/agents_control/rate_limit_watcher.rb +202 -0
- data/lib/agents_control/registry.rb +115 -0
- data/lib/agents_control/reply.rb +41 -0
- data/lib/agents_control/screen_watcher.rb +158 -0
- data/lib/agents_control/secrets.rb +256 -0
- data/lib/agents_control/service.rb +165 -0
- data/lib/agents_control/session.rb +66 -0
- data/lib/agents_control/store.rb +132 -0
- data/lib/agents_control/terminals/base.rb +76 -0
- data/lib/agents_control/terminals/iterm2.rb +167 -0
- data/lib/agents_control/terminals/null.rb +27 -0
- data/lib/agents_control/terminals/tmux.rb +106 -0
- data/lib/agents_control/transcript.rb +123 -0
- data/lib/agents_control/version.rb +5 -0
- data/lib/agents_control/which.rb +59 -0
- data/lib/agents_control.rb +52 -0
- 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,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
|