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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Decides what to do with an agent event: stay quiet, notify, or ask.
5
+ #
6
+ # All the policy lives here, because it's the one part of the system
7
+ # people actually want to tune with settings. Agent adapters only turn
8
+ # a payload into an Event, a channel only displays it — Dispatcher does the thinking.
9
+ class Dispatcher
10
+ # The key distinction: is a human at the keyboard.
11
+ #
12
+ # While they are, intercepting permission requests is counterproductive:
13
+ # they'll answer in the terminal faster than they can reach for their
14
+ # phone, and a blocked hook keeps the dialog from ever reaching the
15
+ # screen at all. So by default this only notifies, and interception
16
+ # is turned on explicitly — with /away before stepping out.
17
+ def initialize(agents:, channel:, config:, pending: nil, logger: nil)
18
+ @agents = agents
19
+ @channel = channel
20
+ @config = config
21
+ @pending = pending || Pending.new
22
+ @logger = logger
23
+ end
24
+
25
+ attr_reader :pending
26
+
27
+ # Returns the hook response body. An empty hash means "no decision" —
28
+ # the agent behaves as it would without us.
29
+ def handle(payload)
30
+ agent = @agents.find { |candidate| candidate.class.handles?(payload) }
31
+ return {} unless agent
32
+
33
+ event = agent.to_event(payload)
34
+ return {} unless event
35
+
36
+ reply = decide(event)
37
+ return {} unless reply
38
+
39
+ agent.to_response(event, reply)
40
+ rescue StandardError => e
41
+ log("failed to handle event: #{e.class}: #{e.message}")
42
+ {}
43
+ end
44
+
45
+ private
46
+
47
+ def decide(event)
48
+ unless event.question?
49
+ notify(event)
50
+ return nil
51
+ end
52
+
53
+ # AskUserQuestion's answer is never delivered through the hook
54
+ # response — only allow/deny is. Blocking here would just hold the
55
+ # hook open for the full reply_timeout and then auto-deny, with
56
+ # nothing gained: the actual answer has to be typed into the
57
+ # terminal, which Channel does directly, unconditionally.
58
+ if event.ask_user_question?
59
+ notify(event)
60
+ return nil
61
+ end
62
+
63
+ # A human is at the keyboard — don't get in their way, just notify.
64
+ unless away?
65
+ notify(event)
66
+ return nil
67
+ end
68
+
69
+ automatic(event) || ask(event)
70
+ end
71
+
72
+ def away? = @config.get("answers.away", false)
73
+
74
+ # What can be decided without a human.
75
+ def automatic(event)
76
+ case event.kind
77
+ when :needs_permission then automatic_permission(event)
78
+ when :needs_input then automatic_input(event)
79
+ end
80
+ end
81
+
82
+ def automatic_permission(event)
83
+ return nil unless @config.get("answers.auto_approve_permissions", false)
84
+ # The forbidden list overrides any automatic setting: these things
85
+ # a human confirms in person, whatever the settings say.
86
+ return nil if forbidden?(event)
87
+
88
+ Reply.allow
89
+ end
90
+
91
+ # A "continue" reply is safe — it grants the agent no new
92
+ # permissions, it only clears the question "should I go on?" That's
93
+ # why it's on by default, unlike automatic tool approval.
94
+ def automatic_input(event)
95
+ return nil unless @config.get("answers.auto_continue", true)
96
+ return nil unless continuation?(event.text)
97
+
98
+ Reply.text("Continue.")
99
+ end
100
+
101
+ CONTINUATION = /(продолж|continue|proceed|shall i|идти дальше|go ahead)/i
102
+
103
+ # Signs that a human is being offered a choice, not asked permission to go on.
104
+ ALTERNATIVE = /(\bили\b|\bлибо\b|\bor\b|^\s*\d[.)]\s|\bвариант)/i
105
+
106
+ # A "should I continue?" question differs from a substantive one in
107
+ # having no subject — it's answerable without reading any context.
108
+ #
109
+ # The word "continue" alone isn't enough for this: "Should I continue
110
+ # the refactor, or show the plan first?" is a choice, and answering
111
+ # "continue" on the human's behalf there means making the decision
112
+ # for them. So the presence of an alternative overrides the
113
+ # automation, even when the word matches.
114
+ def continuation?(text)
115
+ value = text.to_s
116
+ return false if value.empty? || value.length > 300
117
+ return false if value.match?(ALTERNATIVE)
118
+
119
+ value.match?(CONTINUATION)
120
+ end
121
+
122
+ def forbidden?(event)
123
+ haystack = [event.tool_name, event.tool_input.to_s].join(" ").downcase
124
+
125
+ Array(@config.get("answers.never_auto_approve", [])).any? do |needle|
126
+ haystack.include?(needle.to_s.downcase)
127
+ end
128
+ end
129
+
130
+ def ask(event)
131
+ timeout = @config.get("answers.reply_timeout", 600)
132
+ log("asking: #{event.label} — #{event.summary[0, 80]}")
133
+
134
+ reply = @channel.ask(event, pending: @pending, timeout: timeout)
135
+
136
+ # Silence is a refusal, not a permission. If nobody answered while
137
+ # the owner was out, the action doesn't happen.
138
+ log("no answer, declining: #{event.label}") if reply.none?
139
+ reply
140
+ end
141
+
142
+ def notify(event)
143
+ return unless notifiable?(event)
144
+
145
+ log("notifying: #{event.label} — #{event.summary.to_s[0, 80]}")
146
+ @channel.notify(event)
147
+ rescue StandardError => e
148
+ log("failed to notify: #{e.message}")
149
+ end
150
+
151
+ # Not every event can make noise: an agent calls tools dozens of
152
+ # times per turn. Only report what a human actually needs to know about.
153
+ def notifiable?(event)
154
+ case event.kind
155
+ when :error then true
156
+ when :needs_permission, :needs_input then @config.get("answers.notify_when_present", true)
157
+ else false
158
+ end
159
+ end
160
+
161
+ def log(message) = @logger&.puts("[dispatcher] #{message}")
162
+ end
163
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Checks that everything is in place.
5
+ #
6
+ # Main principle: check the environment the daemon will actually get,
7
+ # not the one the user happens to be sitting in. An interactive shell
8
+ # can show a different Ruby, a different PATH, different environment
9
+ # variables than what a separately launched daemon process will see.
10
+ class Doctor
11
+ Check = Struct.new(:name, :status, :detail, :fix, keyword_init: true) do
12
+ def ok? = status == :ok
13
+ def failed? = status == :fail
14
+ def icon = { ok: "✓", warn: "!", fail: "✗" }[status]
15
+ end
16
+
17
+ # AppleScript's error when the user hasn't granted permission to
18
+ # control the app.
19
+ NOT_AUTHORISED = "-1743"
20
+
21
+ def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil)
22
+ @config = config || Config.load
23
+ @secrets = secrets || Secrets.new
24
+ @executor = executor
25
+ @api = api
26
+ end
27
+
28
+ CHECKS = %i[
29
+ ruby_check terminal_check automation_check agent_binary_check
30
+ token_check chats_check bot_check daemon_check hooks_check
31
+ anchors_check wake_check
32
+ ].freeze
33
+
34
+ # Checks run one at a time, each wrapped: a diagnostic tool has no
35
+ # business crashing. An unexpected error is just another result, not
36
+ # the end of the run — otherwise the first small thing would hide
37
+ # everything after it.
38
+ def run
39
+ CHECKS.filter_map do |name|
40
+ send(name)
41
+ rescue StandardError => e
42
+ Check.new(name: name.to_s.sub("_check", ""), status: :fail,
43
+ detail: "check failed: #{e.class}")
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def ok(name, detail) = Check.new(name: name, status: :ok, detail: detail)
50
+ def warn(name, detail, fix = nil) = Check.new(name: name, status: :warn, detail: detail, fix: fix)
51
+ def fail(name, detail, fix = nil) = Check.new(name: name, status: :fail, detail: detail, fix: fix)
52
+
53
+ # The plist gets RbConfig.ruby — the interpreter running this code,
54
+ # so it's guaranteed to work. What's checked isn't that, but what
55
+ # would be found by searching PATH: a version manager's broken shim
56
+ # can sit ahead of the real Ruby there.
57
+ def ruby_check
58
+ running = RbConfig.ruby
59
+ return fail("Ruby", "#{RUBY_VERSION} — needs 3.2+", "update the interpreter") if RUBY_VERSION < "3.2"
60
+
61
+ found = Which.find("ruby")
62
+ shadow = found && found != running && !working_ruby?(found)
63
+
64
+ return warn("Ruby", "#{RUBY_VERSION} — #{running}",
65
+ "a broken #{found} sits earlier on PATH; the service uses " \
66
+ "an absolute path, so this won't get in the way") if shadow
67
+
68
+ ok("Ruby", "#{RUBY_VERSION} — #{running}")
69
+ end
70
+
71
+ def working_ruby?(path)
72
+ result = @executor.run(path, "-e", "print RUBY_VERSION")
73
+
74
+ result.success? && !result.stdout.strip.empty?
75
+ end
76
+
77
+ def terminal_check
78
+ registry = Registry.new(executor: @executor)
79
+ names = registry.available_backends.map(&:name)
80
+
81
+ return warn("Terminal", "no backends available",
82
+ "install tmux or launch iTerm2 — without them only monitoring works") if names.empty?
83
+
84
+ ok("Terminal", names.join(", "))
85
+ end
86
+
87
+ # Permission to control iTerm2 can't be granted programmatically: the
88
+ # TCC database is closed off by SIP, and that's a load-bearing part
89
+ # of macOS security, not an oversight. All that's possible is
90
+ # triggering the prompt at a clear moment, catching the refusal, and
91
+ # opening the right settings pane.
92
+ def automation_check
93
+ return nil unless macos?
94
+ return nil unless iterm_running?
95
+
96
+ result = @executor.run(Which.find("osascript"), "-e",
97
+ 'tell application "iTerm2" to count windows')
98
+
99
+ return ok("iTerm2 permission", "granted") if result.success?
100
+
101
+ if result.stderr.include?(NOT_AUTHORISED)
102
+ fail("iTerm2 permission", "not granted",
103
+ 'open "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"')
104
+ else
105
+ warn("iTerm2 permission", result.stderr.to_s[0, 80])
106
+ end
107
+ end
108
+
109
+ def agent_binary_check
110
+ path = Which.find("claude")
111
+ return warn("Claude Code", "binary not found", "needed for rate-limit anchors") unless path
112
+
113
+ ok("Claude Code", path)
114
+ end
115
+
116
+ def token_check
117
+ source = @secrets.source_for(:telegram_token)
118
+ return fail("Token", "not found", "agents_control setup") unless source
119
+
120
+ insecure = source.respond_to?(:insecure?) && source.insecure?
121
+ return warn("Token", "#{source.name} — insecure storage") if insecure
122
+
123
+ ok("Token", source.name)
124
+ end
125
+
126
+ def chats_check
127
+ chats = Array(@config.get("telegram.allowed_chat_ids", []))
128
+ return fail("Allowed chats", "list is empty — the bot won't answer anyone",
129
+ "agents_control setup") if chats.empty?
130
+
131
+ ok("Allowed chats", chats.join(", "))
132
+ end
133
+
134
+ TOKEN_SHAPE = /\A\d+:[A-Za-z0-9_-]{30,}\z/
135
+
136
+ def bot_check
137
+ token = @secrets.get(:telegram_token)
138
+ return nil unless token
139
+
140
+ # The shape is checked before touching the network: unprintable
141
+ # characters would land straight in the URL and fail the request
142
+ # with a cryptic address-parsing error.
143
+ return fail("Bot", "token doesn't look like one issued by BotFather", "agents_control setup") unless
144
+ token.match?(TOKEN_SHAPE)
145
+
146
+ me = (@api || Channels::Telegram::Api.new(token)).get_me
147
+ ok("Bot", "@#{me['username']}")
148
+ rescue Channels::Telegram::Api::Unavailable
149
+ warn("Bot", "network unavailable — couldn't check")
150
+ rescue Channels::Telegram::Api::Error => e
151
+ fail("Bot", e.message, "check the token: agents_control setup")
152
+ end
153
+
154
+ # A successful connection alone means nothing: something else could
155
+ # be sitting on the port, and in some environments a local connect
156
+ # always succeeds. So a request is sent and the response is
157
+ # inspected — our server refuses a request with no secret, and
158
+ # that's its signature.
159
+ def daemon_check
160
+ port = @config.get("hooks.port", Daemon::DEFAULT_PORT)
161
+
162
+ case daemon_response(port)
163
+ when :ours then ok("Daemon", "running on port #{port}")
164
+ when :stranger then fail("Daemon", "port #{port} is taken by something else",
165
+ "change hooks.port in settings")
166
+ else warn("Daemon", "not running", "agents_control daemon")
167
+ end
168
+ end
169
+
170
+ def daemon_response(port)
171
+ socket = Socket.tcp("127.0.0.1", port, connect_timeout: 1)
172
+ socket.print("POST /ping HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}")
173
+ status = socket.readline
174
+
175
+ status.include?("403") ? :ours : :stranger
176
+ rescue StandardError
177
+ :down
178
+ ensure
179
+ socket&.close
180
+ end
181
+
182
+ def hooks_check
183
+ agent = Agents::ClaudeCode.new
184
+ return ok("Hooks", "connected") if agent.installed?
185
+
186
+ # Not an error: the daemon connects them itself on start and
187
+ # removes them on stop, or the agent would complain about an
188
+ # unreachable address.
189
+ warn("Hooks", "not connected — expected while the daemon is off")
190
+ rescue StandardError => e
191
+ fail("Hooks", e.message)
192
+ end
193
+
194
+ def anchors_check
195
+ return warn("Rate-limit anchors", "off", "turn on in /settings") unless @config.get("anchors.enabled", false)
196
+
197
+ scheduler = Anchors::Scheduler.new(config: @config, store: Store.new)
198
+ at = scheduler.next_run_at
199
+
200
+ ok("Rate-limit anchors", "model #{@config.get('anchors.model')}, next at #{at&.strftime('%d.%m %H:%M')}")
201
+ end
202
+
203
+ # A 7am anchor won't fire if the laptop is asleep: launchd runs the
204
+ # task after waking, and the precise timing is lost.
205
+ def wake_check
206
+ return nil unless macos?
207
+ return nil unless @config.get("anchors.enabled", false)
208
+
209
+ scheduled = @executor.run(Which.find("pmset"), "-g", "sched").stdout.to_s
210
+
211
+ return ok("Wake schedule", "configured") if scheduled.match?(/wake|poweron/i)
212
+
213
+ warn("Wake schedule", "the Mac could be asleep at anchor time",
214
+ "sudo pmset repeat wakeorpoweron MTWRF #{earliest_anchor}:00")
215
+ end
216
+
217
+ def earliest_anchor
218
+ Array(@config.get("anchors.schedule", [])).min.to_s.sub(/\A(\d):/, "0\\1:")
219
+ end
220
+
221
+ def macos? = RUBY_PLATFORM.include?("darwin")
222
+
223
+ def iterm_running?
224
+ ProcessProbe.new(executor: @executor).refresh.running?("iTerm2")
225
+ end
226
+
227
+ def port_open?(port)
228
+ Socket.tcp("127.0.0.1", port, connect_timeout: 1, &:close)
229
+ true
230
+ rescue StandardError
231
+ false
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # A normalized event from an agent.
5
+ #
6
+ # The core and the notification channel operate only on this type and
7
+ # know nothing about Claude Code or Codex. An agent adapter's job is to
8
+ # bring its own payload into this shape, and that's the extent of its
9
+ # knowledge of the outside world.
10
+ #
11
+ # The shape wasn't picked arbitrarily: Claude Code and Codex independently
12
+ # converged on nearly the same set of events and the same way of
13
+ # returning a decision, so this describes an existing commonality rather
14
+ # than a speculative abstraction.
15
+ class Event
16
+ KINDS = %i[
17
+ needs_permission
18
+ needs_input
19
+ finished
20
+ error
21
+ started
22
+ ended
23
+ progress
24
+ ].freeze
25
+
26
+ attr_reader :kind, :agent, :session_id, :cwd, :text, :options,
27
+ :tool_name, :tool_input, :transcript_path, :raw
28
+
29
+ def initialize(kind:, agent:, session_id:, cwd: nil, text: nil, options: [],
30
+ tool_name: nil, tool_input: nil, transcript_path: nil, raw: {})
31
+ raise ArgumentError, "unknown event kind: #{kind}" unless KINDS.include?(kind)
32
+
33
+ @kind = kind
34
+ @agent = agent
35
+ @session_id = session_id
36
+ @cwd = cwd
37
+ @text = text
38
+ @options = options
39
+ @tool_name = tool_name
40
+ @tool_input = tool_input
41
+ @transcript_path = transcript_path
42
+ @raw = raw
43
+ end
44
+
45
+ # Does a human need to weigh in. Everything else is background info.
46
+ def question? = %i[needs_permission needs_input].include?(kind)
47
+
48
+ # AskUserQuestion is structurally a permission request (it goes
49
+ # through the same PermissionRequest hook as any tool call), but
50
+ # answering it means picking one of its listed options — allow/deny
51
+ # doesn't apply, and neither does the usual reply-becomes-hook-response path.
52
+ def ask_user_question? = tool_name == "AskUserQuestion"
53
+
54
+ # Short name of where this is happening. The full path in a
55
+ # notification only gets in the way — what matters in a list is
56
+ # recognizing the project, not seeing /Users/...
57
+ def label
58
+ return File.basename(cwd) if cwd && !cwd.empty?
59
+
60
+ session_id.to_s[0, 8]
61
+ end
62
+
63
+ # Description of what the agent is about to do.
64
+ def summary
65
+ case kind
66
+ when :needs_permission then permission_summary
67
+ when :needs_input then text.to_s
68
+ when :error then "error: #{text}"
69
+ else text.to_s
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # AskUserQuestion isn't a "permission" — it's a question with options
76
+ # to pick from; the tool name won't tell a human anything, the
77
+ # question itself is what matters.
78
+ def permission_summary
79
+ return describe_questions if ask_user_question?
80
+
81
+ "#{tool_name}: #{describe_tool}"
82
+ end
83
+
84
+ # Every tool has its own main field; show that, not the whole JSON.
85
+ def describe_tool
86
+ return "" unless tool_input.is_a?(Hash)
87
+
88
+ value = tool_input["command"] || tool_input["file_path"] ||
89
+ tool_input["pattern"] || tool_input["url"]
90
+
91
+ value.to_s
92
+ end
93
+
94
+ def describe_questions
95
+ questions = tool_input.is_a?(Hash) ? tool_input["questions"] : nil
96
+ return "" unless questions.is_a?(Array)
97
+
98
+ questions.map { |question| describe_question(question) }.join("\n\n")
99
+ end
100
+
101
+ def describe_question(question)
102
+ lines = ["#{question['header']}: #{question['question']}"]
103
+
104
+ Array(question["options"]).each_with_index do |option, index|
105
+ lines << "#{index + 1}. #{option['label']} — #{option['description']}"
106
+ end
107
+
108
+ lines.join("\n")
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AgentsControl
6
+ # The single point through which the utility talks to the outside world.
7
+ #
8
+ # Exists for the "adapter isolation" rule: osascript can hang dead when
9
+ # iTerm2 shows a beachball, and without a hard timeout that takes the
10
+ # whole daemon down with it. No adapter calls Open3 directly.
11
+ #
12
+ # Tests swap this for FakeExecutor — no real terminal needed in CI.
13
+ class Executor
14
+ DEFAULT_TIMEOUT = 10
15
+
16
+ # status 124 — coreutils timeout(1) convention, 127 — "command not found".
17
+ TIMEOUT_STATUS = 124
18
+ NOT_FOUND_STATUS = 127
19
+
20
+ Result = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
21
+ def success? = status.zero?
22
+ def timeout? = status == TIMEOUT_STATUS
23
+ def not_found? = status == NOT_FOUND_STATUS
24
+ end
25
+
26
+ def initialize(timeout: DEFAULT_TIMEOUT)
27
+ @timeout = timeout
28
+ end
29
+
30
+ # Always returns a Result — never raises. A failing external command
31
+ # must not take the daemon down with it.
32
+ def run(*argv, stdin: nil, timeout: @timeout)
33
+ Open3.popen3(*argv) do |input, output, errors, wait_thread|
34
+ write_stdin(input, stdin)
35
+
36
+ # Read on separate threads: otherwise a process that fills the
37
+ # stderr buffer blocks waiting to be read, while we block waiting
38
+ # for it to exit. report_on_exception is off on purpose: on a
39
+ # timeout, the reader threads find popen3's pipe already closed
40
+ # and raise IOError. That's expected and handled below, but Ruby
41
+ # prints that trace to stderr by default, where it looks like a
42
+ # real crash in the utility's console.
43
+ out_reader = Thread.new { output.read }
44
+ err_reader = Thread.new { errors.read }
45
+ [out_reader, err_reader].each { |thread| thread.report_on_exception = false }
46
+
47
+ return kill(wait_thread, out_reader, err_reader, timeout) unless wait_thread.join(timeout)
48
+
49
+ Result.new(
50
+ stdout: out_reader.value.to_s,
51
+ stderr: err_reader.value.to_s,
52
+ status: wait_thread.value.exitstatus || -1
53
+ )
54
+ end
55
+ rescue Errno::ENOENT, Errno::EACCES => e
56
+ Result.new(stdout: "", stderr: e.message, status: NOT_FOUND_STATUS)
57
+ end
58
+
59
+ private
60
+
61
+ def write_stdin(input, data)
62
+ input.write(data) if data
63
+ input.close
64
+ rescue Errno::EPIPE
65
+ # The command exited without reading stdin — that's its right.
66
+ end
67
+
68
+ def kill(wait_thread, *readers, timeout)
69
+ begin
70
+ Process.kill("KILL", wait_thread.pid)
71
+ wait_thread.join
72
+ rescue Errno::ESRCH
73
+ # The process died on its own between join and kill.
74
+ end
75
+
76
+ # Readers are stopped explicitly: otherwise they stay hanging on
77
+ # already-closed pipes and pile up on every timeout.
78
+ readers.each(&:kill)
79
+
80
+ Result.new(stdout: "", stderr: "command didn't respond within #{timeout}s", status: TIMEOUT_STATUS)
81
+ end
82
+ end
83
+ end