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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module AgentsControl
6
+ # Notices Claude Code's local menus on a session's screen.
7
+ #
8
+ # Dialogs the CLI shows itself in response to a local human command —
9
+ # switching models via /model, folder trust on first launch — never
10
+ # produce a hook event at all. The only way to see this state is to
11
+ # read the screen itself.
12
+ #
13
+ # Goes through the full Registry — sees bare iTerm2 tabs too, not just
14
+ # tmux panes, same as RateLimitWatcher.
15
+ #
16
+ # The heuristic is narrow: consecutive lines "❯ 1. …" and " 2. …" —
17
+ # this is how Claude Code draws any choice (folder trust, model switch,
18
+ # a tool permission if it ever made it to the screen), and almost
19
+ # nothing else draws like this.
20
+ class ScreenWatcher
21
+ # "1." next to the cursor marks the first option — the start of a menu.
22
+ CURSOR_OPTION = /^\s*❯\s*1\.\s+(.+?)\s*$/
23
+
24
+ # Later options come without the cursor, just numbered.
25
+ OPTION = /^\s*(\d+)\.\s+(.+?)\s*$/
26
+
27
+ # A 30-line screen comfortably covers any CLI dialog worth catching —
28
+ # Claude Code doesn't draw menus that long.
29
+ LINES = 30
30
+
31
+ def initialize(registry:, config:, store:, api:, logger: nil)
32
+ @registry = registry
33
+ @config = config
34
+ @store = store
35
+ @api = api
36
+ @logger = logger
37
+ @running = false
38
+ end
39
+
40
+ def start
41
+ return self unless enabled?
42
+
43
+ @running = true
44
+ @thread = Thread.new do
45
+ tick while @running
46
+ ensure
47
+ nil
48
+ end
49
+
50
+ self
51
+ end
52
+
53
+ def stop
54
+ @running = false
55
+ @thread&.kill
56
+ end
57
+
58
+ # One pass over all agent sessions. Public method — tests call it
59
+ # directly, without spinning up a real background thread.
60
+ def tick
61
+ candidates.each { |session| check(session) }
62
+ rescue StandardError => e
63
+ log("failure: #{e.class}: #{e.message}")
64
+ ensure
65
+ sleep(interval) if @running
66
+ end
67
+
68
+ private
69
+
70
+ def enabled? = @config.get("terminal.watch_menus", true)
71
+
72
+ def interval = @config.get("terminal.menu_poll_interval", 20)
73
+
74
+ def candidates
75
+ @registry.refresh.agents.reject(&:terminalless?)
76
+ rescue StandardError
77
+ []
78
+ end
79
+
80
+ def check(session)
81
+ options = parse_menu(@registry.backend_for(session).capture(session.id, lines: LINES))
82
+ key = menu_key(session)
83
+
84
+ return @store.delete(key) if options.empty?
85
+
86
+ digest = Digest::SHA1.hexdigest(options.join("\n"))
87
+ return if @store.get(key) == digest # the same dialog — already notified
88
+
89
+ @store.put(digest, ttl: 3600, key: key)
90
+ notify(session, options)
91
+ end
92
+
93
+ def menu_key(session) = "menu:#{session.id}"
94
+
95
+ # Looks for the last textual occurrence of "❯ 1. …" — the one
96
+ # closest to the screen's current state, not a random numbered list
97
+ # left over somewhere higher in the scrollback.
98
+ def parse_menu(text)
99
+ lines = text.to_s.each_line.map(&:chomp)
100
+ start = lines.rindex { |line| line.match?(CURSOR_OPTION) }
101
+ return [] unless start
102
+
103
+ options = [lines[start][CURSOR_OPTION, 1]]
104
+ index = start + 1
105
+
106
+ while index < lines.size && (m = lines[index].match(OPTION)) && m[1].to_i == options.size + 1
107
+ options << m[2]
108
+ index += 1
109
+ end
110
+
111
+ options.size >= 2 ? options : []
112
+ end
113
+
114
+ def notify(session, options)
115
+ text = "🖥 #{session.label} is waiting for a choice in the terminal:\n\n" +
116
+ options.each_with_index.map { |option, i| "#{i + 1}. #{option}" }.join("\n")
117
+
118
+ chats.each do |chat_id|
119
+ sent = @api.send_message(chat_id: chat_id, text: text, reply_markup: buttons(session, options))
120
+ remember_reply(chat_id, sent, session)
121
+ rescue StandardError
122
+ # One unreachable chat must not affect the rest.
123
+ next
124
+ end
125
+ end
126
+
127
+ def remember_reply(chat_id, sent, session)
128
+ id = sent.is_a?(Hash) ? sent["message_id"] : nil
129
+ return unless id
130
+
131
+ @store.put({ "session_id" => session.id, "cwd" => session.cwd, "label" => session.label },
132
+ ttl: 30 * 86_400, key: "reply:#{chat_id}:#{id}")
133
+ end
134
+
135
+ def buttons(session, options)
136
+ rows = options.each_with_index.map do |option, i|
137
+ [{
138
+ text: "#{i + 1}. #{option}"[0, 60],
139
+ callback_data: @store.put({ "action" => "menu_choice", "session_id" => session.id,
140
+ "choice" => i + 1 }, ttl: 3600)
141
+ }]
142
+ end
143
+
144
+ { inline_keyboard: rows }
145
+ end
146
+
147
+ def chats = Array(@config.get("telegram.allowed_chat_ids", []))
148
+
149
+ # log() catches any write failure itself: an exception raised inside
150
+ # a rescue isn't caught by that same rescue, and would kill the
151
+ # thread for good.
152
+ def log(message)
153
+ @logger&.puts("[screen-watcher] #{message}")
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module AgentsControl
7
+ # Storing tokens outside the repo and outside the config.
8
+ #
9
+ # Providers are tried in order; the first available one that answers
10
+ # wins. Implemented by shelling out to system binaries rather than gems:
11
+ # gems like keyring pull in C extensions and break installation for
12
+ # some users, while `security` and `secret-tool` are always present on
13
+ # their respective platforms.
14
+ #
15
+ # The rule that shaped the implementation: **the secret must never land
16
+ # in argv**. Anything passed as an argument is visible in `ps` to any
17
+ # process the user owns, and it lands in shell history. That's why
18
+ # writing to the Keychain goes through `security -i` with the command
19
+ # on stdin, and the CLI never accepts the token as a flag.
20
+ class Secrets
21
+ SERVICE = "agents_control"
22
+
23
+ def initialize(executor: Executor.new, providers: nil)
24
+ @executor = executor
25
+ @providers = providers || default_providers
26
+ end
27
+
28
+ def get(key)
29
+ readable.each do |provider|
30
+ value = provider.get(key.to_s)
31
+ return value if value && !value.empty?
32
+ end
33
+
34
+ nil
35
+ end
36
+
37
+ def set(key, value)
38
+ provider = writable.first
39
+ raise Error, "no secret storage available" unless provider
40
+
41
+ provider.set(key.to_s, value)
42
+ provider
43
+ end
44
+
45
+ def delete(key)
46
+ writable.each { |provider| provider.delete(key.to_s) }
47
+ end
48
+
49
+ # Where a secret lives, and where it will be written — for doctor.
50
+ def source_for(key)
51
+ readable.find { |provider| provider.get(key.to_s) }
52
+ end
53
+
54
+ def target = writable.first
55
+
56
+ private
57
+
58
+ def readable = @providers.select(&:available?)
59
+
60
+ def writable = readable.select(&:writable?)
61
+
62
+ def default_providers
63
+ [
64
+ Providers::Env.new,
65
+ Providers::Keychain.new(executor: @executor),
66
+ Providers::SecretTool.new(executor: @executor),
67
+ Providers::File.new
68
+ ]
69
+ end
70
+
71
+ module Providers
72
+ # Environment variables. Read-only — writing into someone else's
73
+ # process isn't possible. Needed for CI, containers, and headless
74
+ # machines with no keyring daemon.
75
+ class Env
76
+ def name = "environment variable"
77
+ def available? = true
78
+ def writable? = false
79
+
80
+ def get(key) = ENV.fetch(variable_for(key), nil)
81
+
82
+ def set(_key, _value) = raise(Error, "cannot write an environment variable")
83
+
84
+ def delete(_key) = nil
85
+
86
+ def variable_for(key) = "AGENTS_CONTROL_#{key.upcase}"
87
+ end
88
+
89
+ # macOS Keychain.
90
+ class Keychain
91
+ def initialize(executor: Executor.new)
92
+ @executor = executor
93
+ end
94
+
95
+ def name = "Keychain (macOS)"
96
+
97
+ def available? = !binary.nil?
98
+
99
+ def writable? = true
100
+
101
+ def get(key)
102
+ result = @executor.run(binary, "find-generic-password", "-s", SERVICE, "-a", key, "-w")
103
+ return nil unless result.success?
104
+
105
+ decode(result.stdout.strip)
106
+ end
107
+
108
+ # The command goes over stdin in interactive mode, not argv:
109
+ # `security add-generic-password -w SECRET` would expose the
110
+ # token in `ps`, and the man page calls passing a password as an
111
+ # argument insecure outright.
112
+ #
113
+ # The cost of this is that interactive mode splits the line on
114
+ # whitespace, so a value containing a space or newline would be
115
+ # silently mangled. Better to refuse loudly than to save a
116
+ # truncated token and chase a confusing auth error later.
117
+ def set(key, value)
118
+ unless value.to_s.match?(/\A[\x21-\x7E]+\z/)
119
+ raise Error, "Keychain only accepts printable ASCII with no whitespace"
120
+ end
121
+
122
+ command = "add-generic-password -s #{SERVICE} -a #{key} -w #{value} -U\n"
123
+
124
+ @executor.run(binary, "-i", stdin: command).success?
125
+ end
126
+
127
+ def delete(key)
128
+ @executor.run(binary, "delete-generic-password", "-s", SERVICE, "-a", key).success?
129
+ end
130
+
131
+ private
132
+
133
+ # `security -w` prints the value as-is while it's ASCII, but
134
+ # switches to hex the moment other bytes show up inside it.
135
+ # Telegram tokens are always ASCII, but the store is shared —
136
+ # decode that case too, carefully: only when the result actually
137
+ # looks like packed text, not like a secret made of pure hex digits.
138
+ def decode(value)
139
+ return value unless value.match?(/\A(?:\h\h)+\z/) && value.length.even?
140
+
141
+ bytes = [value].pack("H*").force_encoding(Encoding::UTF_8)
142
+ bytes.valid_encoding? && !bytes.ascii_only? ? bytes : value
143
+ end
144
+
145
+ def binary
146
+ @binary = Which.find("security") || false if @binary.nil?
147
+ @binary || nil
148
+ end
149
+ end
150
+
151
+ # Linux: libsecret on top of gnome-keyring or KWallet.
152
+ class SecretTool
153
+ def initialize(executor: Executor.new)
154
+ @executor = executor
155
+ end
156
+
157
+ def name = "libsecret (Linux)"
158
+
159
+ def available? = !binary.nil?
160
+
161
+ def writable? = true
162
+
163
+ def get(key)
164
+ result = @executor.run(binary, "lookup", "service", SERVICE, "account", key)
165
+ result.success? ? result.stdout.chomp : nil
166
+ end
167
+
168
+ # secret-tool reads the secret from stdin on its own — argv stays clean.
169
+ def set(key, value)
170
+ result = @executor.run(
171
+ binary, "store", "--label=#{SERVICE} #{key}",
172
+ "service", SERVICE, "account", key,
173
+ stdin: value
174
+ )
175
+ result.success?
176
+ end
177
+
178
+ def delete(key)
179
+ @executor.run(binary, "clear", "service", SERVICE, "account", key).success?
180
+ end
181
+
182
+ private
183
+
184
+ def binary
185
+ @binary = Which.find("secret-tool") || false if @binary.nil?
186
+ @binary || nil
187
+ end
188
+ end
189
+
190
+ # Last resort: a file with 0600 permissions.
191
+ #
192
+ # Needed where there's no keyring daemon at all — a headless server
193
+ # or a container. Worse than the other options, so we warn about it.
194
+ class File
195
+ def self.path
196
+ base = ENV["XDG_STATE_HOME"] || ::File.expand_path("~/.local/state")
197
+ ::File.join(base, "agents_control", "credentials.json")
198
+ end
199
+
200
+ def initialize(path: self.class.path)
201
+ @path = path
202
+ end
203
+
204
+ def name = "file #{@path} (0600)"
205
+
206
+ def available? = true
207
+
208
+ def writable? = true
209
+
210
+ def insecure? = true
211
+
212
+ def get(key) = read[key]
213
+
214
+ def set(key, value)
215
+ data = read.merge(key => value)
216
+ FileUtils.mkdir_p(::File.dirname(@path))
217
+
218
+ # Permissions are set before writing: otherwise there's a window
219
+ # between file creation and chmod where the secret is
220
+ # world-readable.
221
+ ::File.open(@path, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600) do |file|
222
+ file.write(JSON.generate(data))
223
+ end
224
+
225
+ true
226
+ end
227
+
228
+ def delete(key)
229
+ data = read
230
+ return false unless data.key?(key)
231
+
232
+ set_all(data.except(key))
233
+ end
234
+
235
+ private
236
+
237
+ def read
238
+ return {} unless ::File.exist?(@path)
239
+
240
+ parsed = JSON.parse(::File.read(@path))
241
+ parsed.is_a?(Hash) ? parsed : {}
242
+ rescue JSON::ParserError
243
+ {}
244
+ end
245
+
246
+ def set_all(data)
247
+ ::File.open(@path, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600) do |file|
248
+ file.write(JSON.generate(data))
249
+ end
250
+
251
+ true
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+
6
+ module AgentsControl
7
+ # Autostarting the daemon: launchd on macOS, systemd on Linux.
8
+ #
9
+ # Everything here is built around one rule: **absolute paths**. The
10
+ # service starts not from an interactive shell but from a system
11
+ # manager, where PATH is completely different and can't be relied on.
12
+ class Service
13
+ LABEL = "com.agents-control.daemon"
14
+
15
+ def self.for_platform(**args)
16
+ RUBY_PLATFORM.include?("darwin") ? Launchd.new(**args) : Systemd.new(**args)
17
+ end
18
+
19
+ # The interpreter isn't looked up on PATH, it's the one running this
20
+ # code right now: a PATH search could find a version manager's
21
+ # broken shim ahead of the working Ruby. RbConfig.ruby by definition
22
+ # points at the live interpreter.
23
+ def initialize(ruby: nil, script: nil, logger: $stdout)
24
+ @ruby = ruby || RbConfig.ruby
25
+ @script = script || default_script
26
+ @logger = logger
27
+ end
28
+
29
+ attr_reader :ruby, :script
30
+
31
+ def log_path = File.expand_path("~/Library/Logs/agents_control.log")
32
+
33
+ private
34
+
35
+ # Path to the tool's own executable. The gem might not be installed
36
+ # globally — in that case this is the file straight from the repo.
37
+ def default_script
38
+ File.expand_path("../../exe/agents_control", __dir__)
39
+ end
40
+
41
+ def write(path, contents)
42
+ FileUtils.mkdir_p(File.dirname(path))
43
+ File.write(path, contents)
44
+ path
45
+ end
46
+
47
+ def log(message) = @logger.puts(message)
48
+
49
+ # macOS.
50
+ class Launchd < Service
51
+ def path = File.expand_path("~/Library/LaunchAgents/#{LABEL}.plist")
52
+
53
+ def install
54
+ write(path, plist)
55
+ reload
56
+ path
57
+ end
58
+
59
+ def uninstall
60
+ system("launchctl", "unload", path, out: File::NULL, err: File::NULL) if File.exist?(path)
61
+ FileUtils.rm_f(path)
62
+ end
63
+
64
+ def installed? = File.exist?(path)
65
+
66
+ private
67
+
68
+ def reload
69
+ system("launchctl", "unload", path, out: File::NULL, err: File::NULL)
70
+ system("launchctl", "load", path, out: File::NULL, err: File::NULL)
71
+ end
72
+
73
+ def plist
74
+ <<~XML
75
+ <?xml version="1.0" encoding="UTF-8"?>
76
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
77
+ <plist version="1.0">
78
+ <dict>
79
+ <key>Label</key>
80
+ <string>#{LABEL}</string>
81
+
82
+ <!-- Absolute paths are mandatory: launchd's PATH isn't yours. -->
83
+ <key>ProgramArguments</key>
84
+ <array>
85
+ <string>#{ruby}</string>
86
+ <string>#{script}</string>
87
+ <string>daemon</string>
88
+ </array>
89
+
90
+ <key>RunAtLoad</key>
91
+ <true/>
92
+
93
+ <!-- The daemon crashes, launchd brings it back up. Pending
94
+ questions are lost in the process, but nobody's left
95
+ waiting on them anyway: the connection to the agent
96
+ dropped along with the process, and the agent moved on by itself. -->
97
+ <key>KeepAlive</key>
98
+ <true/>
99
+
100
+ <key>StandardOutPath</key>
101
+ <string>#{log_path}</string>
102
+ <key>StandardErrorPath</key>
103
+ <string>#{log_path}</string>
104
+
105
+ <key>EnvironmentVariables</key>
106
+ <dict>
107
+ <key>PATH</key>
108
+ <string>#{safe_path}</string>
109
+ </dict>
110
+ </dict>
111
+ </plist>
112
+ XML
113
+ end
114
+
115
+ # The same order as in binary lookup: version managers and ARM
116
+ # brew ahead of the system directories.
117
+ def safe_path
118
+ ["#{Dir.home}/.asdf/shims", "/opt/homebrew/bin", "/opt/homebrew/sbin",
119
+ "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":")
120
+ end
121
+ end
122
+
123
+ # Linux.
124
+ class Systemd < Service
125
+ def path = File.expand_path("~/.config/systemd/user/agents-control.service")
126
+
127
+ def log_path = "journal"
128
+
129
+ def install
130
+ write(path, unit)
131
+ system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
132
+ system("systemctl", "--user", "enable", "--now", "agents-control",
133
+ out: File::NULL, err: File::NULL)
134
+ path
135
+ end
136
+
137
+ def uninstall
138
+ system("systemctl", "--user", "disable", "--now", "agents-control",
139
+ out: File::NULL, err: File::NULL)
140
+ FileUtils.rm_f(path)
141
+ end
142
+
143
+ def installed? = File.exist?(path)
144
+
145
+ private
146
+
147
+ def unit
148
+ <<~UNIT
149
+ [Unit]
150
+ Description=agents_control — control AI agents from Telegram
151
+ After=network-online.target
152
+
153
+ [Service]
154
+ Type=simple
155
+ ExecStart=#{ruby} #{script} daemon
156
+ Restart=always
157
+ RestartSec=5
158
+
159
+ [Install]
160
+ WantedBy=default.target
161
+ UNIT
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # One session — a terminal tab, or an agent with no terminal at all.
5
+ #
6
+ # Deliberately knows nothing about Claude or iTerm2: this is the common
7
+ # denominator the core and Telegram operate on. Anything backend-specific
8
+ # lives in the adapters.
9
+ class Session
10
+ attr_reader :id, :backend, :tty, :title, :cwd, :agent, :agent_pid, :foreground_command
11
+
12
+ def initialize(id:, backend: nil, tty: nil, title: nil, cwd: nil,
13
+ processing: nil, at_shell_prompt: nil,
14
+ agent: nil, agent_pid: nil, foreground_command: nil)
15
+ @id = id
16
+ @backend = backend
17
+ @tty = tty
18
+ @title = title
19
+ @cwd = cwd
20
+ @processing = processing
21
+ @at_shell_prompt = at_shell_prompt
22
+ @agent = agent
23
+ @agent_pid = agent_pid
24
+ @foreground_command = foreground_command
25
+ end
26
+
27
+ # Terminal is busy working. iTerm2 has `is processing`; tmux has no
28
+ # direct equivalent, so the value can be nil — "unknown", not "no".
29
+ def processing? = @processing == true
30
+
31
+ def at_shell_prompt? = @at_shell_prompt == true
32
+
33
+ # Agent presence is confirmed by the process tree, not the tab title.
34
+ def agent? = !@agent.nil?
35
+
36
+ # A session without a tab: launched from VS Code or another GUI
37
+ # client. Can't be controlled through a terminal, only reached via hooks.
38
+ def terminalless? = @backend.nil? || @tty.nil?
39
+
40
+ # Short name for the Telegram list: the directory name is more
41
+ # informative than a full path or a title that may be stale.
42
+ def label
43
+ return File.basename(cwd) if cwd && !cwd.empty?
44
+ return title if title && !title.empty?
45
+
46
+ id.to_s
47
+ end
48
+
49
+ def with(**attrs)
50
+ Session.new(**to_h.merge(attrs))
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ id: id, backend: backend, tty: tty, title: title, cwd: cwd,
56
+ processing: @processing, at_shell_prompt: @at_shell_prompt,
57
+ agent: agent, agent_pid: agent_pid, foreground_command: foreground_command
58
+ }
59
+ end
60
+
61
+ def ==(other) = other.is_a?(Session) && other.to_h == to_h
62
+ alias eql? ==
63
+
64
+ def hash = to_h.hash
65
+ end
66
+ end